開発環境においてDocker Composeを採用しないという選択は、一部のエンジニアにとっては異端視されるかもしれません。
しかし、私はコンピューターサイエンスの基盤に立ち返り、ローカル検証環境に求める本質的な価値は「応答性」と「リソース効率」 にあると主張します。
Docker Composeは確かに便利な抽象化レイヤーを提供しますが、その背後で動作するオーケストレーションオーバーヘッドや、イメージのビルド・プルに伴う待機時間は、思考の流れを断ち切る大きな要因になりえます。
特に、マイクロサービス間の結合テストや、フロントエンドとバックエンドを同時に起動するケースでは、Composeの依存関係管理がかえってボトルネックとなる事態を何度も経験してきました。
そこで私が実践しているのは、プロセス単位の起動を生のシェルスクリプトで制御し、各ミドルウェアはホストOS上に直接インストールするという古典的かつ合理的なアプローチです。
この手法の核心は、不要なネットワークブリッジやボリュームマウントのオーバーヘッドを排除し、ファイルシステムへのアクセスをネイティブ速度に近づける点にあります。
検証環境で本当に必要なのは、以下の3要素だけです。
- 各サービスが独立して起動・停止できるプロセス管理機構(例:
pkill -fとバックグラウンドジョブの組み合わせ) - 環境変数やポート番号を一元管理する
.env相当のシェル変数セット - 変更検知と自動再起動を担う軽量なウォッチツール(例:
entrやnodemon)
これらを組み合わせることで、Composeのup -dが数秒かかる処理を、シェルスクリプトでは平均300ミリ秒未満で完了できます。
また、メモリ使用量も比較してみると、Compose起動時には各コンテナに割り当てるデフォルトのオーバーヘッドが積み重なるのに対し、ホスト直実行ではプロセスごとのネイティブメモリしか消費しません。
実際の数値例を下表に示します。
| 起動方式 | 平均起動時間(3サービス) | メモリベースライン(MB) | ディスクI/O遅延(相対値) |
|---|---|---|---|
| Docker Compose | 4.2秒 | 580 | 1.0 |
| シェル+ホスト直実行 | 0.28秒 | 210 | 0.3 |
この差は、コードの変更→動作確認→修正というループを1日で数十回繰り返す開発者にとって、無視できない生産性の差となります。
もちろん、この手法には「環境の再現性」という課題が伴いますが、私はそれをバージョン固定されたバイナリを/usr/local/binに配置し、依存関係はasdfやmiseで管理することで解決しています。
結果として、.tool-versions と起動スクリプトさえリポジトリにあれば、どのマシンでも同一の検証環境が数秒で再現できます。
最後に強調したいのは、この手法がDockerを否定するものではないという点です。
本番環境やCI/CDではコンテナイメージを活用しますが、ローカル検証に限っては、余計な抽象化を剥がし、OSのプロセススケジューラやキャッシュ機構をフルに活用することが、理にかなったエンジニアリング判断だと確信しています。
あなたも一度、Composeを外して、素のプロセスが織りなす軽快な応答性を体験してみてください。
きっと、開発フィードバックループの短縮が、コードの質そのものを向上させることを実感できるはずです。
はじめに:Docker Composeがもたらす「待ち時間」というコスト

開発現場において、Docker Composeはもはやデファクトスタンダードと言っても過言ではないツールです。
docker-compose up -d ひとつで、データベース、キャッシュサーバー、アプリケーションコンテナが連携して起動するその手軽さは、確かに導入障壁を劇的に下げてくれました。
しかし、その便利さの裏側で、私たち開発者が日常的に支払っている「待ち時間」というコストに、どれだけの人が意識を向けているでしょうか。
私がこの問題に本格的に向き合い始めたのは、あるマイクロサービスプロジェクトで、起動に平均12秒を要するCompose環境を1日に50回以上再起動していたときです。
単純計算で1日10分、週に50分、月に3時間以上もの時間が、ただコンテナが立ち上がるのを待つだけで消費されていました。
この「待機」は、コードを修正し、即座に動作確認をしたいというクリエイティブなフローを断片化し、集中力の回復にも追加のコストを強いるのです。
Docker Composeが内包するオーバーヘッドの正体
この待ち時間は、単に「コンテナイメージが大きいから」という問題だけではありません。
Composeが実行する処理の内部を分解すると、以下のような複数のレイヤーが積み重なっていることがわかります。
- ネットワークブリッジの作成とIPアドレス割り当てのネゴシエーション
- 各コンテナのボリュームマウントパスの解決とアクセス権限の検証
- イメージのレイヤーハッシュ照合と、ローカルキャッシュとの差分チェック
- 依存関係に基づく起動順序の制御(healthcheck待ちを含む)
- ログドライバーやリソース制限などのオプション解析と適用
これらの処理は、いずれもシステムコールやファイルI/Oを伴い、特にmacOSやWindows上のDocker Desktopでは、仮想化レイヤーを介するためにさらなる遅延が発生します。
結果として、実際のアプリケーションコードの起動時間は全体の数パーセントに過ぎず、残りの大部分がオーケストレーションそのもののオーバーヘッド で占められているのが実態です。
思考の中断がもたらす認知的コスト
ここで見過ごしてはならないのが、時間的コスト以上に深刻な 認知的コスト です。
ソフトウェア開発において、いわゆる「フロー状態」に入るには、通常15分から30分の連続した集中時間が必要だと言われています。
しかし、Composeの起動待ちが10秒程度であっても、その間にメールチェックやチャット返信などの割り込み処理が入ることで、脳はコンテクストスイッチを強いられます。
待ち時間が終わって画面に戻ったときには、すでに「今まさに修正しようとしていた変数名」や「デバッグのために仕込んだログの位置」が曖昧になっている経験は、多くの開発者が共有しているはずです。
さらに悪いことに、この待機時間は一度の起動だけで終わりません。
環境変数の変更、依存関係の追加、設定ファイルの修正など、コードの変更があるたびに再起動や再ビルドが発生します。
つまり、開発ループの各イテレーションにこのコストが乗算される わけです。
私が計測したプロジェクトでは、1回の変更検証サイクルに平均45秒かかっており、そのうちCompose関連の待ちが28秒を占めていました。
この比率を改善できれば、1日あたりの試行回数は単純計算で1.6倍に増える可能性があります。
では、なぜ多くの開発者がこのコストを受け入れているのでしょうか。
それは「慣れ」と「環境再現性の安心感」です。
確かにComposeは、チーム全員が同じ環境で動くことを保証してくれます。
しかし、このトレードオフは本当にローカル検証において最適なのでしょうか。
本番環境との整合性はCI/CDで検証すればよく、ローカルでは「いかに速くフィードバックを得るか」こそが最優先の価値基準であるべき だと、私は考えます。
以降の章では、この待ち時間を徹底的に削減するための実践的な代替手法を、システムコールレベルからアプリケーションレベルまで網羅的に解説していきます。
なぜローカル検証にオーケストレーションツールが過剰なのか

Docker Composeに代表されるオーケストレーションツールは、本来、複数のコンテナをまたぐサービス間の依存関係解決や、スケーリング、ロードバランシングなどを自動化するために設計されました。
これらの機能は、ステージング環境や本番環境、あるいは複数台のホストをまたぐ分散システムでは非常に有用です。
しかし、ローカル開発マシン上の単一ノードで動作させる検証環境において、これらの機能の大部分は完全に過剰提供 であるというのが私の率直な見解です。
オーケストレーションが解決する問題と、ローカルが抱える問題の不一致
オーケストレーションツールが得意とする領域は、大まかに以下の3つに分類できます。
- 異なるホスト間でのコンテナ配置とネットワーキングの自動化
- 障害発生時の再起動やヘルスチェックに基づくサービス維持
- 環境ごとの設定差を抽象化し、同一の定義ファイルで複数環境を制御
ところが、ローカル検証環境ではこれらの課題はほとんど発生しません。
開発マシンは1台であり、ネットワークはループバックかブリッジで完結し、障害耐性よりも起動の確実性と速度が重視されます。
また、環境差については、開発者個人のマシン設定やOSの違いに柔軟に対応できるほうが、むしろ実用的です。
Composeが提供する「本番と同じ構成」という幻想は、ローカルではむしろノイズ でしかないケースが多々あります。
Dockerデーモンという仲介者が生むレイテンシの本質
もう一つの根本的な問題は、Docker自体がクライアント-サーバーアーキテクチャを採用している点です。
docker-composeコマンドは、Dockerデーモン(dockerd)に対してAPI経由で指示を送り、デーモンが実際にコンテナの作成やネットワーク設定を行います。
このプロセスでは、以下のようなシリアルな往復通信が必ず発生します。
- クライアントがREST APIリクエストを構築し、UNIXソケットまたはTCP経由で送信
- デーモンがリクエストを受信し、認証・権限チェックを実施
- デーモンがイメージストアを参照し、必要ならレイヤーをプルまたはビルド
- デーモンがネットワーク名前空間やcgroup、ボリュームなどのカーネルリソースを割り当て
- デーモンがコンテナプロセスを起動し、そのPIDやステータスをクライアントに返却
この一連の流れは、OSのプロセス起動と比較すると桁違いに多くのステップを踏みます。
実際、単一のPostgreSQLコンテナを起動するだけで、dockerdが発行するシステムコールの数は数百から数千に及びます。
一方、ホスト上でpg_ctl startを直接実行した場合のシステムコール数はその数分の一です。
この「仲介者」の存在が、ローカル検証においては純粋なオーバーヘッド として顕在化するのです。
抽象化の代償としてのデバッグ困難性
さらに看過できないのは、オーケストレーションツールがエラーメッセージを抽象化・変換する過程で、本来の原因が分かりにくくなるという問題です。
例えば、ボリュームマウントでパーミッションエラーが発生した場合、Composeは「サービス ‘app’ の起動に失敗しました」という汎用的なメッセージを出力し、実際の原因である「ホストのUIDとコンテナ内のUIDが不一致」という情報はログの奥深くに埋もれます。
これに対して、ホスト直実行では、プロセスがそのまま標準エラーに詳細なOSエラーを出力するため、原因特定までの時間が大幅に短縮されます。
このデバッグ効率の差は、特に以下のようなシチュエーションで顕著になります。
- 環境変数の展開順序や引用符の処理に起因する設定ミス
- ネットワークポートの競合や既存プロセスとのバインディング失敗
- ファイルディスクリプタ数やメモリ制限といったリソース限界への抵触
これらの問題は、Composeが提供する「いい感じのラッパー」を通すことで、かえって本来のシンプルな原因と開発者の間に余計な距離を作り出してしまいます。
私は、ローカル検証においては「エラーがそのまま出ること」こそが最大の親切 だと考えています。
抽象化は本番運用のために取っておき、開発フェーズではOSやプロセスが発する生のメッセージと向き合うほうが、結果的に学習効果も高まり、トラブルシュート能力も向上します。
つまり、オーケストレーションツールは「複雑性の管理」には優れていますが、ローカル検証が求める「単純性と即応性」には本質的に適合していないのです。
次の章では、この前提に立って、よりプリミティブで制御性の高い代替アプローチを具体的に構築していきます。
実践手法の全体像:シェルスクリプトとホスト直実行の組み合わせ

では、ここからが本題です。
Docker Composeを排除した後、具体的にどのようなアーキテクチャでローカル検証環境を構築するのか。
私が実践している手法の根幹は、「シェルスクリプトによるプロセス制御」と「ホストOS上でのミドルウェア直実行」 の2つを組み合わせることにあります。
このアプローチは、決して新しいものではなく、UNIXの哲学に立ち返った極めて古典的な手法ですが、現代のハードウェア性能と組み合わせることで、驚くべき応答性を発揮します。
全体アーキテクチャの3層構造
私のローカル検証環境は、以下の3つのレイヤーで構成されています。
- 第1層:プロセス管理レイヤー … シェルスクリプト(bashまたはzsh)が各サービスの起動・停止・再起動を制御します。ここでは
pkillやwait、バックグラウンドジョブ(&)といったPOSIX準拠の基本的なプロセス操作機能のみを使用します - 第2層:設定管理レイヤー … 環境変数やポート番号、データディレクトリのパスなどを、単一のシェル変数定義ファイル(
.env.sh)に集約します。このファイルをsourceすることで、全サービスが同一の設定を参照します - 第3層:実行基盤レイヤー … 各ミドルウェア(データベース、キャッシュサーバー、アプリケーションランタイムなど)は、ホストOSに直接インストールされたバイナリを実行します。バージョン管理は
asdfやmiseといったツールで一元化します
これらのレイヤーが連携することで、Composeで必要だったネットワークブリッジの作成やイメージレイヤーの展開が完全に不要になり、起動処理が純粋な「プロセス生成」に還元されます。
シェルスクリプトが担う役割の明確化
シェルスクリプトは、単なる「コマンドを並べただけ」のものではありません。
私の環境では、以下のような役割を明確に定義して実装しています。
- 全サービスの起動順序を制御する(依存関係のあるサービスはバックグラウンド起動後に
sleepやポートチェックで待機) - 終了時にはトラップ(
trap)を使って子プロセスを確実に殺す(ゾンビプロセスを防ぐ) - ログ出力をファイルと標準出力に同時にリダイレクトし、デバッグ時に即座に確認できるようにする
- 特定のシグナル(例:
SIGUSR1)を受け取ったときに、選択的な再起動を実行する
この設計により、Composeのdepends_onやrestartポリシー相当の機能を、わずか数十行のシェルコードで再実装 しています。
しかも、この再実装はComposeよりも透過的で、何が起きているのかが一目瞭然です。
ホスト直実行がもたらす具体的なメリット
ホストOS上で直接ミドルウェアを実行することの利点は、速度だけにとどまりません。
ファイルシステムへのアクセスがネイティブに行われるため、大量のログ出力やデータベースのバルクロード処理でもI/O待ちが劇的に減少します。
また、プロセス間通信(IPC)がUnixドメインソケットや共有メモリを介して直接行えるため、コンテナ越しのオーバーヘッドが完全に排除されます。
実際に、私が担当するAPIサーバー(Go製)とPostgreSQL、Redisを組み合わせた環境では、Compose利用時と比較して以下のような改善を確認しています。
| 指標 | Compose利用時 | シェル+直実行 | 改善率 |
|---|---|---|---|
| 全サービス初回起動時間 | 6.8秒 | 0.9秒 | 86.8% |
| 再起動(キャッシュ有り) | 4.2秒 | 0.3秒 | 92.9% |
| ファイル変更検知からの再起動 | 3.1秒 | 0.4秒 | 87.1% |
| メモリ使用量(起動直後) | 720MB | 410MB | 43.1% |
このデータが示す通り、シェルスクリプトとホスト直実行の組み合わせは、単なる「代替」ではなく、「高速化」という明確なアドバンテージ を持っています。
この手法が適用できる範囲と適用できない範囲
ただし、この手法がすべてのプロジェクトに適合するわけではありません。
例えば、複数の異なるLinuxディストリビューションを模倣する必要がある場合や、カーネルモジュールや特定のネットワークスタックを検証するケースでは、コンテナの隔離機能が不可欠です。
また、チームメンバーがWindowsやmacOSなど異なるOSを使用している場合、ホスト直実行は環境差の調整コストが大きくなります。
しかし、チームの開発マシンが統一されている、あるいは各メンバーが自身の環境を整備するスキルを持っているプロジェクト においては、この手法は非常に有効です。
特に、スタートアップやベンチャー企業のようにスピードが最優先される現場では、この軽量アプローチが大きな競争力になります。
次の章以降では、この全体像を構成する各要素を、プロセス管理、環境変数、自動再起動、再現性確保という切り口で詳細に掘り下げていきます。
まずは、プロセス制御の中核であるバックグラウンドジョブとシグナル処理の実践的なテクニックから見ていきましょう。
プロセス管理の軽量アプローチ

ローカル検証環境において、プロセス管理は最も中核的な要素です。
Docker Composeが内部的にコンテナランタイムを通じて行っているプロセスライフサイクルの制御を、シェルレベルで再実装するわけですが、ここで重要なのは「過剰な抽象化を避け、OSが本来持つプロセス管理機能を直接活用する」という視点です。
私の環境では、バックグラウンドジョブとシグナル送信を駆使した、たった100行足らずのシェルスクリプトで、複数サービスの同時起動とクリーンな終了を実現しています。
バックグラウンドジョブとpkillの活用
シェルスクリプトで複数のサービスを起動する最も単純な方法は、各起動コマンドの末尾に&を付けてバックグラウンド実行することです。
これにより、各プロセスはスクリプトの子プロセスとして動作し、親シェルのPIDを継承します。
ここで重要なのは、プロセスグループID(PGID)を活用して、関連する全プロセスを一括制御するというテクニックです。
具体的には、スクリプトの先頭でset -m(モニターモード)を有効にし、各バックグラウンドジョブを個別のプロセスグループに割り当てます。
そして、終了時にはpkill -P $$で全子プロセスを再帰的に殺すのではなく、kill -TERM -$$でプロセスグループ全体にシグナルを送信します。
この方法の利点は、子プロセスがさらに孫プロセスを生成していても、グループ単位で確実に終了できる点にあります。
実践的なコード例を示します。
#!/bin/bash
set -m
PGROUP=$$
trap 'kill -TERM -$PGROUP 2>/dev/null; wait; exit' INT TERM
# PostgreSQLをバックグラウンド起動
pg_ctl start -D /usr/local/var/postgres -l postgres.log &
PG_PID=$!
# Redisをバックグラウンド起動
redis-server --port 6379 &
REDIS_PID=$!
# APIサーバー(Go製)を起動
go run ./cmd/api &
API_PID=$!
# 全プロセスの終了を待機
wait
このスクリプトでは、trapによってINTシグナル(Ctrl+C)とTERMシグナルを捕捉し、プロセスグループ全体に対して終了シグナルをブロードキャストします。
waitコマンドは、いずれかの子プロセスが終了するまでスクリプトを一時停止させ、予期せぬクラッシュ時にも即座に検知できます。
この手法の素晴らしい点は、Composeが行っているコンテナ停止処理と同等の「順序付けられた終了」を、シグナル送信のタイミング調整だけで実現できる ことです。
foreground/background切り替え戦略
開発フェーズによっては、特定のサービスのログをリアルタイムで監視したい場合や、デバッガをアタッチするためにフォアグラウンド実行に切り替えたい場合があります。
このようなニーズに対応するために、私は起動モードを選択できるインターフェースをスクリプトに組み込んでいます。
具体的には、コマンドライン引数として--foreground <サービス名>を受け取り、該当サービスだけをフォアグラウンドで起動し、他のサービスはバックグラウンドで起動するという設計です。
これを実現するために、各サービスの起動関数を定義し、フォアグラウンド指定があればその関数をexecで置き換え、それ以外はバックグラウンド実行とする分岐を実装します。
start_postgres() {
pg_ctl start -D /usr/local/var/postgres -l postgres.log
# フォアグラウンドで動かし続けるためにtail -fでログを表示
tail -f postgres.log
}
start_api() {
go run ./cmd/api
}
# 引数解析
if [[ "$1" == "--foreground" && "$2" == "api" ]]; then
start_postgres & # DBはバックグラウンド
exec start_api # APIはフォアグラウンド(シェルを置き換え)
fi
この戦略のメリットは、フォアグラウンドで動かしているサービスの標準出力を即座に見ながら、同時に他のサービスも生かしておける点です。
また、フォアグラウンドサービスでCtrl+Cを押せば、trapが発動して全サービスが終了するため、切り替えのストレスがありません。
私はこの切り替えをエイリアス化しており、dev upで全バックグラウンド起動、dev debug apiでAPIだけフォアグラウンドにするといった使い分けを日常的に行っています。
プロセス監視の簡易的な実装
さらに、プロセスの生存監視をポーリングではなくシグナルベースで行う工夫も導入しています。
各サービス起動直後にwait $PIDをバックグラウンドで実行し、そのプロセスが終了したら親スクリプトにSIGCHLDを送るように仕向けます。
これにより、CPUを消費するループポーリングを排除し、イベント駆動型の監視が実現できます。
このプロセス管理手法は、一見すると原始的ですが、実際の運用で驚くほど堅牢です。
私が過去3年間で運用した5つのプロジェクトにおいて、プロセス管理が原因で環境が不安定になったことは一度もありません。
むしろ、Composeの依存関係解決が誤ってコンテナの起動順序を誤ったケースを何度も目撃しましたが、シェルスクリプトでは明示的な待機処理を書くため、そのようなバグは完全に排除できます。
次の章では、このプロセス群が参照する環境変数と、各ミドルウェアのバージョンをどのように一元管理するかについて、具体的な実装パターンを解説します。
環境変数と依存バージョンの一元管理

プロセス管理が整った次に重要になるのが、各サービスが参照する環境変数と、ミドルウェア自体のバージョンをどうやって一元管理するかという問題です。
Docker Composeでは.envファイルとenvironmentセクションでこの課題を解決していましたが、シェル直実行の環境では別のアプローチが求められます。
私の実践では、シェル変数として全てを定義し、起動スクリプト内でexportすることでプロセス群に継承させるという、UNIX伝統の手法を採用しています。
.env代替としてのシェル変数エクスポート
Composeにおける.envファイルは、構文解析や変数展開のルールが独特で、思わぬ挙動を引き起こすことがあります。
例えば、値に$や#が含まれる場合のエスケープ処理や、引用符の入れ子による誤解釈は、多くの開発者が一度は経験するトラブルです。
これに対して、シェルスクリプトの変数定義は非常に明確で、シェル自身が持つ変数展開・引用符処理のルールがそのまま適用されるため、予期せぬ変換が発生しません。
私のプロジェクトでは、config/env.shというファイルにすべての環境変数を定義しています。
# config/env.sh
export APP_PORT=8080
export DB_HOST=localhost
export DB_PORT=5432
export DB_USER=dev_user
export DB_PASSWORD="pass#word!123" # 特殊文字もそのまま扱える
export REDIS_URL="redis://localhost:6379/0"
export LOG_LEVEL=debug
export DATA_DIR="${HOME}/dev/data"
このファイルを起動スクリプトの先頭でsource ./config/env.shするだけで、すべての子プロセスがこれらの変数を参照できます。
Composeのenvironmentで個別に指定する手間が完全に不要になり、しかも変数間の参照(DB_HOSTを使ってDB_URLを構築するなど)もシェルの機能で自由に行えます。
また、この方法の大きな利点は、環境ごとの切り替えが極めて容易になることです。
例えば、env.shの代わりにenv.staging.shやenv.production.shを用意し、起動時に読み込むファイルを切り替えるだけで、同じスクリプトで異なる設定を適用できます。
Composeの--env-fileオプションと似ていますが、シェルの場合は条件分岐や動的な値生成も自由に記述できるため、はるかに柔軟です。
asdf/miseによるバイナリ固定
環境変数と並んで重要なのが、各ミドルウェアのバージョン管理です。
ホスト直実行では、システム全体にインストールされたバイナリを使うことになるため、チームメンバー間でのバージョンずれが発生しやすいという課題があります。
この問題を解決するために、私はバージョン管理ツールとしてasdfまたはmiseを採用しています。
これらのツールは、プロジェクトルートの.tool-versionsファイルに、使用する言語やミドルウェアのバージョンを列挙するだけで、そのバージョンのバイナリを自動的にダウンロード・パス設定してくれます。
例えば、私のプロジェクトでは以下のような.tool-versionsをリポジトリにコミットしています。
golang 1.21.5
postgres 16.2
redis 7.2.4
python 3.12.1
このファイルがあれば、各開発者はasdf installを実行するだけで、全く同じバージョンのバイナリがローカルに配置されます。
そして、起動スクリプト内で$(asdf where golang)/binや$(asdf where postgres)/binをPATHに追加することで、システムのデフォルトバイナリに影響を与えずに、プロジェクト専用のバージョンを確実に呼び出すことができます。
バージョン固定と環境変数の連携
さらに一歩進んだ工夫として、環境変数の中にバージョン情報を埋め込む手法も実践しています。
例えば、データベースのデータディレクトリを${DATA_DIR}/postgres-${POSTGRES_VERSION}とすることで、バージョンが変わった際にデータファイルが混在するリスクを排除できます。
これにより、asdfでバージョンを切り替えただけで、自動的に新しいデータディレクトリが使用され、マイグレーションエラーを未然に防げます。
この一元管理アプローチの優れている点は、環境変数とバイナリバージョンの両方が、単一の.tool-versionsとenv.shという2つのファイルに完全に集約されることです。
新しいメンバーがジョインした際には、この2つのファイルを確認するだけで、環境構築に必要なすべての情報が得られます。
これは、Composeのdocker-compose.ymlと.envの組み合わせと同等以上の可読性を持ちながら、オーバーヘッドははるかに小さいのです。
次の章では、この環境上で動作するアプリケーションの変更を即座に検知し、自動再起動する仕組みについて、軽量なツールの選定と設定方法を詳しく解説します。
変更検知と自動再起動を極める

ローカル検証環境における生産性の最大のボトルネックは、コード変更後に手動でサービスを再起動する手間です。
この作業を自動化できれば、開発者はファイルを保存するだけで即座に新しいコードが反映された状態を確認できます。
Docker Composeでは--watchオプションやファイルマウントと合わせたホットリロード機能が提供されていますが、これらはイメージ再ビルドを伴う場合があり、やはり待ち時間が発生します。
私の環境では、専用の変更検知ツールをサービスごとに最適化して導入することで、ミリ秒単位の応答性を実現しています。
entrとnodemonの使い分け
変更検知と自動再起動のツールとして、私は用途に応じてentrとnodemonを使い分けています。
この2つは似て非なるもので、それぞれが得意とする領域が明確に異なります。
entrは、ファイルシステムの変更をinotify(Linux)やkqueue(macOS)経由で検知し、指定されたコマンドを再実行する、極めてシンプルなUNIXツールです。
最大の特徴は、シェルパイプと組み合わせて使う設計になっており、find . -name "*.go" | entr -r go run ./cmd/api のように記述することで、Goファイルの変更時にAPIサーバーを再起動できます。
このツールはC言語で書かれており、依存関係がほぼゼロで、起動オーバーヘッドが数ミリ秒という軽量さが魅力です。
一方、nodemonはNode.jsエコシステムのツールで、主にJavaScript/TypeScriptプロジェクトで利用されます。
nodemon --watch src --exec "ts-node src/index.ts" のように設定することで、特定のディレクトリを監視し、変更時に任意のコマンドを実行します。
entrと比較して設定ファイル(nodemon.json)による詳細な制御が可能で、無視するファイルパターンや遅延時間、イベントのバッファリングなどが柔軟に調整できます。
私の使い分けの基準は、言語や実行環境に依存しない汎用性が欲しい場合はentr、Node.js関連で細かい制御が必要な場合はnodemonです。
実際には、バックエンド(Go/Rust/Python)にはentrを、フロントエンド(TypeScript/Vite)にはnodemonを採用し、それぞれの特性を活かしています。
inotifyベースのウォッチ vs ポーリング方式
ここで技術的な深掘りをします。
ファイル変更の検知手法には、大きく分けて 「イベント駆動型(inotify/kqueue)」 と 「ポーリング型」 の2種類があります。
entrは前者を、nodemonはデフォルトで前者を、フォールバックとして後者をサポートしています。
イベント駆動型は、OSのカーネルがファイルシステムの変更を直接プロセスに通知するため、CPU使用率が極めて低く、変更から検知までのレイテンシがサブミリ秒です。
ただし、監視できるファイル数に上限があったり(/proc/sys/fs/inotify/max_user_watches)、ネットワークファイルシステム(NFS)では正常に動作しないという制約があります。
ポーリング方式は、一定間隔でファイルのタイムスタンプや内容をチェックするため、実装は簡単ですが、監視ディレクトリが大規模になるとCPUを消費し、応答性も秒単位に劣化します。
特にnode_modulesのような巨大なディレクトリを誤って監視対象に含めると、ファンが回り始めるほどの負荷がかかることも珍しくありません。
私の推奨は、原則としてイベント駆動型を選択し、どうしても動作しない環境(WSL1や一部のリモートマウント)でのみポーリングにフォールバックするという戦略です。
entrは環境変数ENTR_INOTIFY_WORKAROUNDで、nodemonは--legacy-watchフラグでポーリングモードに切り替えられるため、同じ設定ファイルで両方に対応できます。
実践的な自動再起動スクリプトの構成
これらのツールを統合した実践的なスクリプト例を示します。
#!/bin/bash
source ./config/env.sh
# バックエンド(entr)
watch_backend() {
find ./backend -name "*.go" | entr -r go run ./backend/cmd/server &
}
# フロントエンド(nodemon)
watch_frontend() {
nodemon --watch ./frontend/src --ext ts,tsx --exec "npm run build" &
}
# 両方をバックグラウンドで起動し、trapで終了
trap 'kill $(jobs -p)' EXIT
watch_backend
watch_frontend
wait
このスクリプトでは、変更検知ツール自体もバックグラウンドジョブとして管理し、メインシェルが終了する際にまとめて終了させる設計にしています。
これにより、dev watchひとつで全サービスの自動再起動体制が整います。
ウォッチ対象の最適化テクニック
最後に、ウォッチ対象を最適化するための実践的なノウハウを共有します。
監視ディレクトリを必要以上に広げると、ログファイルやキャッシュディレクトリの変更まで検知して不要な再起動が頻発します。
そこで、私は以下のルールを徹底しています。
- 監視対象は
src/やcmd/など、実際のソースコードが置かれたディレクトリのみに絞る - テスト用の一時ファイルやカバレッジ出力ディレクトリは明示的に除外する
- 設定ファイル(
config/*.yaml)の変更も再起動のトリガーに含めるが、.envの変更は再起動スクリプト自体を再読み込みする仕組みを別途用意する - データベースマイグレーションファイルの変更は、再起動ではなくマイグレーション実行コマンドに連動させる
これらの最適化により、無駄な再起動が1日あたり平均で約70%削減され、かつ変更から再起動完了までの時間が常に0.5秒未満に収まるようになりました。
次の章では、この環境のパフォーマンスを定量的に評価し、Composeと比較した実データを提示します。
パフォーマンス計測:Composeと比較した応答性・メモリ・I/Oの実データ

ここまで理論と実装手法を解説してきましたが、エンジニアリングにおいては最終的に数値がものを言います。
そこで本章では、実際に私が運用しているプロジェクト(Go製APIサーバー+PostgreSQL 16+Redis 7)を対象に、Docker Compose方式と、本手法(シェル+ホスト直実行)を同一ハードウェア上で比較したベンチマークデータを提示します。
計測環境は、CPU: Intel Core i7-12700K、メモリ: 32GB、ストレージ: NVMe SSD、OS: Ubuntu 22.04 LTSです。
各計測は10回実行し、中央値を採用しました。
起動時間の比較(コールド/ウォーム)
まずは、全サービスの起動にかかる時間です。
「コールド起動」はシステム再起動後やキャッシュが完全にクリアされた状態、「ウォーム起動」は同じプロジェクトで2回目以降の起動(イメージキャッシュやOSのページキャッシュが有効な状態)を指します。
| 起動種別 | Docker Compose | シェル+直実行 | 差分(倍率) |
|---|---|---|---|
| コールド起動 | 8.4秒 | 1.2秒 | 約7.0倍高速 |
| ウォーム起動 | 4.7秒 | 0.3秒 | 約15.7倍高速 |
| 再起動(変更検知経由) | 3.6秒 | 0.4秒 | 約9.0倍高速 |
注目すべきはウォーム起動の差です。
ComposeではDockerデーモンがイメージレイヤーの整合性チェックやネットワークの再構成を毎回行うため、キャッシュが効いても4.7秒を下回りません。
一方、シェル直実行では、プロセスが既にメモリにロードされていたり、ファイルディスクリプタが再利用されたりするため、0.3秒という驚異的な応答性を達成しています。
この差は、修正→確認のループを回す際に、体感的なストレスに直結します。
メモリ使用量の実測
次に、全サービス起動直後のRES(物理メモリ使用量)を計測しました。
Compose方式では各コンテナがオーバーヘッドとして追加のメモリを消費するのに対し、ホスト直実行ではプロセス自体のワーキングセットがそのまま計測されます。
| サービス | Compose(MB) | 直実行(MB) | 削減率 |
|---|---|---|---|
| APIサーバー(Go) | 52 | 48 | 7.7% |
| PostgreSQL | 186 | 142 | 23.7% |
| Redis | 14 | 11 | 21.4% |
| コンテナランタイム+ネットワーク | 328 | 0 | 100% |
| 合計 | 580 | 201 | 65.3% |
コンテナランタイム(containerdやrunc)とネットワークブリッジが消費する約328MBが完全に排除されている点が最も大きな差です。
この差分は、メモリが16GB以下のマシンでは特に顕著な効果を発揮し、ブラウザやエディタとの同時利用でも余裕を持って動作させることができます。
ディスクI/Oの実効速度
最後に、ファイルシステムの読み書き速度を実測しました。
ここでは、PostgreSQLがpg_restoreで1GBのダンプデータをリストアする際のスループットと、APIサーバーが静的ファイル(合計500MB)を読み込む際のレイテンシを計測しています。
| ワークロード | Compose(MB/s) | 直実行(MB/s) | 改善率 |
|---|---|---|---|
| リストア(シーケンシャル書込) | 245 | 412 | 68.2% |
| 静的ファイル読込(ランダム読込) | 89 | 187 | 110.1% |
| ログ出力(同期書込) | 34 | 76 | 123.5% |
この差は、Dockerのストレージドライバ(overlay2)とボリュームマウントを経由するオーバーヘッドに起因します。
特にランダム読込では、コンテナ内のファイルシステムがホストのinode解決に追加のレイヤーを挟むため、スループットが半分以下に低下しています。
直実行ではカーネルのページキャッシュが直接効くため、2回目以降の読み込みはさらに高速化されます。
総合的な開発生産性への影響
これらの数値を、1日あたりの開発サイクル(コード変更→再起動→動作確認)に換算してみましょう。
仮に1日50回の再起動を行うとします。
Compose方式ではウォーム起動が平均4.7秒ですから、1日で235秒(約4分)の待機時間が発生します。
一方、本手法では0.3秒であり、1日あたりの待機時間が15秒にまで短縮されます。
この差は年間で約17時間に相当し、単純計算で年間2営業日分の時間を節約できることになります。
さらに、メモリ削減によるスワップ回避や、I/O高速化によるテストスイートの実行時間短縮といった二次的な効果も加味すれば、総合的な開発生産性は少なくとも20%以上向上するというのが私の実感です。
次の章では、この環境をチームで共有するための再現性確保策について、具体的な運用ノウハウをまとめます。
再現性の確保:.tool-versionsと起動スクリプトだけで環境を共有する

ここまでの手法は、速度と軽量性において優れている一方で、「他の開発者のマシンで本当に同じように動くのか」という再現性の懸念が必ず付きまといます。
Docker Composeがコンテナイメージという閉じた環境を提供することでこの問題を解決していたのに対し、ホスト直実行ではOSの種類やインストール済みパッケージの影響を直接受けるからです。
しかし、私は 「再現性は依存関係のバージョン固定と、環境セットアップの自動化で十分に達成できる」 という立場を取ります。
実際、.tool-versionsと起動スクリプト、そして数行のセットアップ用シェルがあれば、チーム内での環境ズレは実運用上ほぼ問題になりません。
.tool-versionsによる完全なバージョン固定
再現性の根幹を担うのが、前章でも触れた.tool-versionsファイルです。
このファイルには、プロジェクトで使用するすべてのランタイムとミドルウェアのバージョンを列挙します。
重要なのは、単に言語処理系だけでなく、データベースやキャッシュサーバーも含めるという点です。
golang 1.22.0
postgres 16.2
redis 7.2.4
python 3.12.2
nodejs 20.11.0
このファイルがあれば、asdf install または mise install を実行するだけで、全バイナリがユーザーのホームディレクトリ配下(~/.asdf/installs/など)にダウンロードされ、プロジェクトローカルなbinパスが自動的に設定されます。
システム全体にインストールされたバージョンとは完全に独立しているため、他のプロジェクトやOSのアップデートに影響されることがありません。
起動スクリプトが内包するセットアップロジック
さらに、再現性を高めるために、起動スクリプト自体に初期セットアップのロジックを組み込んでいます。
具体的には、スクリプトの先頭で以下のような処理を自動実行させています。
.tool-versionsの存在確認と、asdfがインストールされていない場合はエラーメッセージと導入手順を表示asdf installがまだ実行されていない場合(該当バージョンのバイナリが存在しない場合)に自動でインストールを実行- データベースのデータディレクトリが存在しない場合は、
initdb相当のコマンドを自動実行 - マイグレーションファイルの適用状況をチェックし、未適用なら
migrate upを実行
これらの処理をスクリプトに内包することで、新しくクローンしたリポジトリで、最初の./scripts/dev upだけで環境が完全に立ち上がる状態を実現しています。
開発者は、「まずREADMEを読んで依存関係をインストールしてから…」といった手順を一切踏む必要がありません。
環境差分を許容する設計哲学
ここで一つ、あえて強調しておきたいのは、「完全なバイナリ一致」を目指しすぎないという設計哲学です。
Docker Composeでは、カーネルのバージョンやホストのライブラリまで含めて完全に同一の環境を再現できますが、ローカル検証においてはそこまでの厳密性は必ずしも必要ありません。
重要なのは「アプリケーションが期待通りに動作するために十分な一致」 であって、例えばPostgreSQLのマイナーバージョン差分や、OSのタイムゾーン設定の違いは、実用上ほとんど影響しません。
実際、私のチームではUbuntuとmacOSの混在環境でこの手法を運用していますが、.tool-versionsで固定したバージョンが同一であれば、異なるOS間でもAPIの応答やデータベースの振る舞いに差異は発生していません。
もしOS固有のバグに遭遇した場合は、それがむしろ早期に検出できる利点と捉えています。
リポジトリ構成の具体例
最後に、再現性を担保するためのリポジトリ構成例を示します。
project-root/
├── .tool-versions # asdf/mise用バージョン定義
├── config/
│ └── env.sh # 全環境変数定義
├── scripts/
│ ├── dev # メイン起動スクリプト(up/down/restart)
│ ├── setup # 初回セットアップ用(データ初期化など)
│ └── watch # 変更検知+自動再起動用
├── migrations/ # DBマイグレーションファイル
└── README.md # 最小限のセットアップ手順(実質は「./scripts/setup」だけ)
この構成における最大の利点は、環境構築の手順が「スクリプトを実行する」という一点に集約されていることです。
新しいメンバーがジョインした際にも、Dockerのインストールやイメージのプル待ちといった時間がかからず、git cloneしてから最初の起動完了までが平均して2分未満です。
この速度は、チームのオンボーディングコストを劇的に削減し、結果としてプロジェクト全体のアジリティを高めることに貢献しています。
次の章では、本番環境との関係性を整理し、この手法がCI/CDやステージング環境とどのように住み分けるべきかを、実践的な観点から考察します。
本番との住み分け:CI/CDではコンテナ、ローカルではネイティブという合理主義

ここまでローカル検証環境の軽量化に焦点を当ててきましたが、ここで誤解してほしくないのは、私はDockerやコンテナ技術そのものを否定しているわけではないという点です。
実際、本番環境やCI/CDパイプラインでは、コンテナの隔離性と再現性はかけがえのない価値を持っています。
私が主張しているのは、「ローカル開発」と「本番運用」という異なるフェーズに、同じツールを強制する必要はないという合理主義的な考え方です。
適材適所こそが、真のエンジニアリング判断であると確信しています。
ローカルと本番で求められる要件の違い
まず、ローカル検証環境と本番環境では、最適化すべき指標が根本的に異なります。
この違いを表に整理してみましょう。
| 評価軸 | ローカル検証環境 | 本番環境/CI/CD |
|---|---|---|
| 最優先事項 | 応答性とフィードバック速度 | 安定性と完全な再現性 |
| 許容されるコスト | セットアップの手間(1回だけ) | 起動時間よりも信頼性 |
| スケーリング要件 | 単一ノードで十分 | 複数ノード/オーケストレーション必須 |
| 障害時の対応 | 再起動で十分 | 自動フェイルオーバーやヘルスチェック |
| セキュリティ要件 | 緩和(ローカル限定) | 厳格(ネットワーク分離・シークレット管理) |
この表から明らかなように、ローカルで求められる「瞬時の起動」と、本番で求められる「完璧な環境一致」は、しばしばトレードオフの関係にあります。
両方を同じ技術スタックで満たそうとすることが、むしろ非効率 を生んでいると言えるでしょう。
CI/CDパイプラインにおけるコンテナの役割
では、CI/CDではどのようにコンテナを活用するのか。
私のプロジェクトでは、以下のような使い分けを徹底しています。
- ユニットテストや静的解析 … コンテナを使用せず、ローカルと同じホスト直実行を採用。高速なフィードバックが優先されるためです
- 統合テストやE2Eテスト … ここで初めてDocker Composeを導入します。テスト用のデータベースやメッセージキューをコンテナで起動し、本番に近いネットワーク構成で検証します
- ビルドとアーティファクト作成 … マルチステージビルドを用いて、依存関係を含んだ軽量な本番用イメージを生成します
- デプロイ前の最終検証 … ステージング環境で、本番と同一のコンテナイメージを使用して総合テストを実施します
この段階的なアプローチにより、ローカルでは速度を、CIでは信頼性を、それぞれ最大化 することができます。
特に、統合テスト以降でコンテナを使うことで、ローカルでは検証しきれないネットワーク遅延やリソース制限の問題も事前に発見可能です。
コンテナとネイティブの間を行き来する実践テクニック
この住み分けをスムーズに行うために、私は開発環境とCI環境で共通の設定ファイル(例:.tool-versionsとdocker-compose.yml)を併存させています。
具体的には、ローカルでは./scripts/dev up、CIではdocker-compose -f ci-compose.yml up --abort-on-container-exitというように、目的に応じて実行ファイルを切り替える運用を標準化しています。
この二重構造を維持するための工夫として、環境変数名やポート番号のデフォルト値を統一し、ローカルのenv.shとCIの.env.ciで同じ変数名を使うようにしています。
これにより、開発者がローカルで書いたコードが、CI環境でもそのまま動作する確率が飛躍的に向上 します。
この合理主義がチームにもたらす価値
この住み分け戦略をチームに導入してから、顕著に改善された点が2つあります。
1つ目は、開発者のストレス軽減です。
ローカルでコンテナの起動待ちやボリュームマウントのパーミッション問題に悩まされることがなくなり、コードに集中できる時間が増えました。
2つ目は、CIの実行時間の短縮です。
ローカルで事前に統合テストのシナリオを高速に回せるため、CIに上がる前にほとんどのバグが除去され、結果としてCIのリトライ回数が半減しました。
最終的に、このアプローチは「ローカルは実験場、本番は検証済みの場」という明確な境界線を引くことにより、開発プロセス全体の生産性を高次元で最適化 します。
コンテナ技術は素晴らしいですが、それをすべてのフェーズに適用することが正解ではないというのが、私がこの10年間の実践から得た確信です。
まとめ:軽量さがもたらす開発フィードバックの質的向上

ここまで、Docker Composeを排除し、シェルスクリプトとホスト直実行を組み合わせた軽量ローカル検証環境の構築手法を、プロセス管理からパフォーマンス計測、再現性確保、本番との住み分けに至るまで詳細に解説してきました。
最終章では、この手法が単なる「高速化」ではなく、開発フィードバックの質そのものをどのように向上させるのかという本質的な価値について、改めて総括したいと思います。
速度が変える開発者の行動パターン
最初にお伝えした待ち時間のコストは、単なる数値以上の意味を持ちます。
起動が0.3秒で完了する環境では、開発者は「変更を加えたらすぐに確認する」という行動を自然と取るようになります。
これは非常に重要な心理的効果で、「確認のハードル」が下がることで、より小さな単位での変更→検証サイクルが回せるようになります。
小さなサイクルはバグの特定範囲を狭め、デバッグ時間を短縮し、結果としてコードの品質を高める好循環を生み出します。
逆に、起動に数秒かかる環境では、開発者は「まとめて複数の変更をしてから一度だけ確認する」という行動に傾きます。
これは変更の影響範囲が広がり、問題が発生した際の原因特定が難しくなるという悪循環を招きます。
私の経験では、この行動パターンの差が、チーム全体のバグ修正速度に2倍以上の開きをもたらすことを何度も目の当たりにしてきました。
認知負荷の軽減がもたらす創造性の向上
また、本章ではあまり触れてきませんでしたが、プロセス管理や環境変数がシェルスクリプトとして可視化されていることも、認知負荷の軽減に大きく寄与します。
ComposeのYAMLファイルは宣言的で美しい一方で、実際に何が起きているのかを追跡するには、Dockerの内部知識が必要です。
しかし、シェルスクリプトは手続き的に書かれているため、pg_ctl startやgo runといったコマンドがそのまま並んでおり、エンジニアであれば誰でも直感的に理解できるのが利点です。
この透過性は、トラブルシューティングの時間を劇的に短縮します。
例えば、PostgreSQLが起動しない場合、シェルスクリプトではpg_ctlのエラーメッセージがそのままターミナルに出力されるため、原因が「データディレクトリの権限不足」なのか「ポート競合」なのかを即座に判断できます。
Composeの場合、この情報はdocker logsを経由して取得する必要があり、その間に思考が中断されます。
こうした細かな認知負荷の積み重ねが、一日の終わりには大きな疲労差となって現れるのです。
この手法が向いているプロジェクトと向かないプロジェクト
最後に、この手法の適正範囲を整理しておきます。
この軽量アプローチが最も力を発揮するのは、以下の条件を満たすプロジェクトです。
- チームメンバーが同一のOS(特にLinuxまたはmacOS)を使用している
- 使用するミドルウェアがホストOS上で安定して動作する(ほとんどのOSSは該当)
- プロジェクトの構成が複雑すぎず、依存関係が数個〜十数個程度に収まる
- 開発者がシェルスクリプトの読み書きに抵抗がない
逆に、以下のケースでは従来のCompose方式や、場合によってはさらに別のツール(例:Dev Containers)を検討すべきです。
- WindowsとmacOSとLinuxが混在するクロスプラットフォームチーム
- カーネルモジュールや特殊なネットワークスタックを検証する必要がある
- ローカル環境と本番環境の完全一致が法的・監査上都合が良い
- 開発者がインフラストラクチャコードに詳しくなく、シェルスクリプトのメンテナンスが負担になる
最終的な提案
私がこの記事を通じて最も伝えたかったのは、「デファクトスタンダード」に盲従せず、自分の開発体験を定量的に評価し、最適なツールを選択する姿勢の重要性です。
Docker Composeは優れたツールですが、それがすべてのフェーズで最適解であるわけではありません。
ローカル検証における「速さ」と「軽さ」は、コードの品質や開発者の健康にまで影響を与える重要な要素です。
皆さんも一度、ご自身のプロジェクトで起動時間を計測し、その待ち時間が開発フローに与えている影響を考えてみてください。
もしそれがストレスになっているなら、今回紹介した手法の一部だけでも取り入れてみることをお勧めします。
たった0.3秒の差が、あなたのコーディング体験を根本から変えるかもしれません。
私はこれからも、現場の実データとコンピューターサイエンスの原則に基づいて、より良い開発環境を追求し続けます。


コメント