git worktree × Docker Composeによる並行開発環境の改善

  • URLをコピーしました!

こんにちは、PR TIMESでインターンをしている笹山雷雅です。

レビュー中や検証中に、いま触っているブランチをそのまま残したまま、別ブランチの挙動を確認したくなる場面は少なくありません。

ただ、git switch を繰り返す運用では、未コミット変更を気にしたり、作業の文脈が途切れたりしやすく、修正前後を見比べるにも手間がかかります。

そこで今回は、git worktree と Docker Compose を組み合わせて、作業中ブランチを切り替えずに別ブランチを https://<slug>.app.dev.local で並行確認できるようにした構成を紹介します。

この記事では、PR TIMES のローカル開発環境で実際に試した構成を、公開向けに一般化して紹介します。

目次

この記事の要約

  • git worktree と Docker Compose を組み合わせ、作業中ブランチを切り替えずに別ブランチを確認できるようにした
  • アプリケーション本体だけを worktree ごとに分離し、DB や OpenSearch などの重い依存サービスは共有した
  • リバースプロキシとローカルドメインを使い、既存のローカル開発環境に近い導線で並行検証できるようにした
  • add / down / rm の操作をスクリプトに閉じ込め、日常的に回しやすい運用にした

git worktree だけでは足りなかった

git worktree は、1つのリポジトリの Git 管理情報を共有したまま、複数の作業ツリーを持てる機能です。

あわせて読みたい

レビュー対象ブランチや比較用ブランチを別ディレクトリとして持てるため、現在の作業ブランチを汚さずに別の変更を確認しやすくなります。

ただし、ローカル開発環境が Docker Compose 前提の場合、worktree を増やしただけでは十分ではありません。どのディレクトリをコンテナへマウントするかが固定されやすく、worktree ごとにそのまま切り替えるのは難しいためです。

検証時の前提環境は、macOS 上で Docker Desktop を利用するローカル環境です。特定のバージョンへの強い依存を主題にした記事ではありませんが、git worktree、Docker Compose、リバースプロキシを組み合わせて動かせることを前提にしています。

導入の要点

導入にあたって前提になったのは次の4点でした。

  • 既存のメイン開発環境が立ち上がっていること
  • worktree ごとにアプリケーション本体だけを切り替えられる Compose 定義を分けること
  • 既存のローカル開発環境が HTTPS 前提だったため、https://<slug>.app.dev.local へ流すリバースプロキシ設定を動的に作ること
  • /etc/hosts やローカル証明書の設定は、既存のローカル設定を壊さないよう一部手動運用にすること

ここでは説明のため、ローカルドメインを app.dev.local と表記します。

メインの開発環境を前提にしたうえで、worktree 用には bin/worktree.shdocker-compose.worktree.yml を追加しました。共有サービスは既存環境のものをそのまま使い、worktree 側では web コンテナだけを増やします。

導入後に開発者が実際に触る操作はかなり少なく、基本は以下です。

# 既存ブランチから worktree を作成して起動
bash bin/worktree.sh add feature/add-preview-page
# 一覧確認
bash bin/worktree.sh list
# 停止
bash bin/worktree.sh down feature-add-preview-page
# 削除
bash bin/worktree.sh rm feature-add-preview-page

down はコンテナだけを止めて worktree を残すコマンドで、rm は worktree ディレクトリ、コンテナ、リバースプロキシ向けの動的設定までまとめて削除するコマンドです。

add を実行すると、ブランチ名を slug 化した worktree ディレクトリを作り、Compose プロジェクト名を分けて web-<slug> コンテナを起動します。その後、https://<slug>.app.dev.local へ流す nginx 設定と証明書を用意し、/etc/hosts に追加すべき行も表示します。技術的な導入価値は単なる worktree 作成ではなく、既存の HTTPS 前提のローカル環境に、追加した worktree を違和感なく載せられることにあります。

ここを完全自動にしなかったのは、ホスト OS 側の共有設定をスクリプトが直接削除するのは危険だからです。特に /etc/hosts は他のローカル設定とも共存しているため、意図しない既存設定の破壊を避けることを優先しました。

この記事で前提にする既存環境

以降は、既存の Compose ベース開発環境がすでに動いていることを前提に説明します。

具体的には、次のような構成を想定しています。

  • アプリケーション本体を動かす web コンテナがある
  • Postgres や OpenSearch、Valkey などの依存サービスが Compose で起動している
  • リバースプロキシが Docker ネットワーク経由で upstream を名前解決できる
  • app.dev.local のようなローカルドメインと証明書をすでに利用している

この記事では、この既存環境を土台にして、worktree ごとに web コンテナだけを追加する構成を扱います。

なお、ローカル開発で常に HTTPS が必須というわけではありません。今回は、既存のローカル開発環境が app.dev.local と証明書を前提に組まれていたため、worktree 側もその前提に合わせています。

ファイル配置と責務

今回追加したものは、役割ごとに次の場所へ置いています。

repo-root/
bin/worktree.sh
docker-compose.worktree.yml
worktrees/<slug>/
proxy/conf.d/worktree-<slug>.conf
certs/<slug>.app.dev.local.crt
keys/<slug>.app.dev.local.key
  • bin/worktree.sh: worktree の作成、起動、停止、削除をまとめて扱うスクリプト
  • docker-compose.worktree.yml: worktree ごとの web コンテナだけを起動する Compose 定義
  • worktrees/<slug>/: git worktree が作る実体ディレクトリ
  • proxy/conf.d/worktree-<slug>.conf: worktree ごとの動的 nginx 設定
  • certs / keys : worktree ドメイン用の証明書と秘密鍵

設計方針

以降で何度か触れますが、今回の設計判断の中心は一つです。共通で利用するものと、ブランチごとに切り出すものを先に決め、そのうえで既存の手順を崩さない形に寄せることでした。

今回の設計で重視したのは次の3点です。

  • アプリケーション本体だけを worktree ごとに分離する
  • DB、OpenSearch、Valkey などの依存サービスは共有する
  • 動的ポートだけに頼らず、既存のローカル開発環境に合わせてホスト名ベース + HTTPS で確認できるようにする

全体像を図にすると次のようになります。worktree ごとに増えるのは web コンテナだけで、依存サービスは既存環境のものを共有します。

worktree導入後の構成図

実装の要点

今回の改善でこだわったのは、単に「worktree を作れるようにした」で終わらせず、「毎日ストレスなく使えるか」という点です。動的ポートの直アクセスだけに頼らず、普段検証に使っているステージング環境に近いホスト名ベースにすることで、開発体験のギャップを減らすことを目指しました。

PR TIMES のステージング環境については、以下の記事で紹介しています。

あわせて読みたい
1台のサーバーで複数のステージング環境を同時に使えるようにする こんにちは、インフラチームテックリードの櫻井です。 今回は1台のサーバーで複数のステージング環境を同時に使用できるように設定を変更したので、その方法について紹...

たとえば feature/amain の worktree を同時に立ち上げておけば、修正前後の挙動を 2 つの URL で見比べながらレビューできます。ここから先は、そのために何をどう設計したかを順に紹介します。

何を共有し、何を worktree ごとに分けるか

実装としては、通常のローカル開発環境で立ち上げている共有コンテナ群を既存の Docker ネットワーク上に置いたまま、worktree 用の Compose 定義からそのネットワークへ外部参加させています。こうすることで、docker compose up 自体は worktree 側のアプリケーションコンテナ起動だけで済みつつ、アプリケーションからは同じネットワーク越しに既存の Postgres や OpenSearch、Valkey へそのまま接続できます。

以下の Compose 定義は説明用に単純化した抜粋です。実際の構成にはアプリケーション固有の設定ファイルや証明書マウントもありますが、ここでは worktree 化に関係する部分だけを示します。

services:
web:
build: ./web
container_name: web-${WORKTREE_NAME}
volumes:
- ${WORKTREE_PATH}:/app
ports:
- "127.0.0.1::443"
networks:
- shared-dev-network
networks:
shared-dev-network:
external: true

この例では、既存のローカル開発環境が HTTPS 前提のため、worktree ごとの web-${WORKTREE_NAME} コンテナも 443/TCP で待ち受ける構成にしています。ports: "127.0.0.1::443" は、その 443 を host OS の loopback (127.0.0.1) にのみ公開し、ホスト側のポート番号は Docker に自動割り当てさせる設定です。

なお、これは worktree の仕組み上 HTTPS が必須という意味ではありません。ローカル開発環境全体を HTTP 前提で組んでいる場合は、同じ考え方を HTTP で構成することもできます。

通常時の確認はリバースプロキシ経由の URL で確認しつつ、補助的な確認として、 Docker が作成時に動的に割り当てたホスト側ポート番号宛の https://app.dev.local:<port> を使用できます。このポート番号は作成時にURL で表示するようにしていますが、 docker compose port <service> 443 で確認することもできます。

ホスト側ポートを公開しているのは、リバースプロキシ設定やローカルドメインを介さずに切り分け確認できるようにするためです。

※ホスト側のソケットは 127.0.0.1:<port> にバインドされますが、ブラウザで開く URL は https://app.dev.local:<port> を使います。これは、既存のローカル証明書やホスト名ベースの挙動に揃えるためです。

この環境では、アプリケーション側も app.dev.local を前提にした設定や挙動を持っているため、localhost127.0.0.1 をそのまま開いて同じように動かせるわけではありません。そのため、この記事でも既存の app.dev.local 前提の構成に絞って説明します。

この Compose 定義で重要なのは、worktree 側が既存の Docker ネットワークに参加している点です。これにより、worktree の web-${WORKTREE_NAME} から既存の postgresopensearch を同じ名前で参照できます。

加えて、起動スクリプト側で WORKTREE_PATHWORKTREE_NAME を環境変数として渡し、Compose プロジェクト名も worktree ごとに分けています。

つまり、毎回すべてのコンテナを増やしているのではなく、「ブランチごとに増えるのはアプリケーション本体だけ」「重い依存サービスは共通ネットワーク上で再利用する」という分担にしています。この切り分けによって、並行開発できる自由度と、日常的に回せる軽さの両立を狙いました。

管理スクリプト

設計の要点は前節でほぼ決まっているので、ここではそれをどう操作に落としたかだけを見ます。

主要な操作として add / list / down / rm / ps をまとめた管理スクリプトを用意しました。worktree ごとに Compose プロジェクト名を変えつつ、必要な環境変数を渡すことで、起動や停止を定型化しています。

worktree_compose() {
local name="$1"
local worktree_path="$2"
shift 2
WORKTREE_PATH="${worktree_path}" \\
WORKTREE_NAME="${name}" \\
docker compose \\
-f "${COMPOSE_FILE}" \\
-p "app-${name}" \\
"$@"
}

ブランチ名には / などホスト名や Compose プロジェクト名にそのまま使いづらい文字が含まれるため、/- に置き換えたうえで小文字化し、利用しづらい文字を整形した slug に変換して利用しています。

# ブランチ名をコンテナ名・ディレクトリ名として安全なslugに変換
slug() {
local s
s=$(echo "$1" \\
| tr '/' '-' \\
| tr '[:upper:]' '[:lower:]' \\
| tr -c 'a-z0-9-\\n' '-' \\
| tr -s '-')
s="${s#-}"
s="${s%-}"
echo "$s"
}

add の分岐は次のようにしています。

  • ローカルブランチが存在する場合: そのブランチで worktree を作る
  • origin/<branch> だけが存在する場合: --track -b 付きでローカルブランチを作って worktree を作る
  • どちらにも存在しない場合: 現在の HEAD を基点に新規ブランチを切って worktree を作る

また、slug 化したディレクトリがすでに存在する場合は、その worktree が同じブランチを指しているかを確認し、別ブランチなら衝突としてエラーにしています。

利用イメージは次のとおりです。

bash bin/worktree.sh add feature/ticket-name
bash bin/worktree.sh list
bash bin/worktree.sh down feature-ticket-name
bash bin/worktree.sh rm feature-ticket-name
bash bin/worktree.sh ps

add を実行すると、ブランチ名を slug 化した worktree ディレクトリを作成し、Compose プロジェクト名を分けて web-<slug> コンテナを起動します。

その後、https://<slug>.app.dev.local へ流す nginx 設定と証明書を生成し、/etc/hosts に追加すべき行を表示します。

この構成の価値は、単に worktree を作ることではなく、既存の HTTPS 前提のローカル開発環境に追加した worktree を自然に載せられることにあります。

成功時の出力は概ね次のようになります。

Creating worktree for 'feature/add-preview-page' at ...worktrees/feature-add-preview-page ...
Starting app worktree 'feature-add-preview-page' ...
Generating TLS certificate for feature-add-preview-page.app.dev.local ...
Generating nginx conf for feature-add-preview-page.app.dev.local ...
⚠ /etc/hosts に以下の行を追加してください:
echo '127.0.0.1 feature-add-preview-page.app.dev.local' | sudo tee -a /etc/hosts
✓ Worktree 'feature-add-preview-page' が起動しました
URL : <https://feature-add-preview-page.app.dev.local>
URL : <https://app.dev.local>:<port> (ポート直接)
停止 : bin/worktree.sh down feature-add-preview-page
Running composer install for worktree 'feature-add-preview-page' ...
✓ composer install 完了

通常の確認は https://<slug>.app.dev.local を使います。

一方で https://app.dev.local:<port> は、 worktree 用のリバースプロキシ設定を介さずに切り分け確認するための補助的なURLです。接続先のポート番号はDockerが動的に割り当てますが、HTTPSのホスト名には既存のローカル証明書が有効な app.dev.local を使います。

ここで重要なのは、開発者が毎回 Compose プロジェクト名や環境変数、設定ファイルの生成先を意識しなくてよいことです。設計の複雑さはスクリプト側へ寄せています。

依存解決は起動中の web コンテナへ入って実行するのではなく、同じ Compose 定義を使った一時コンテナで実行しています。

リバースプロキシとローカルドメイン

この構成の特徴は、worktree を追加しても既存の確認導線を崩さないことです。

通常の web コンテナと https://app.dev.local はそのまま残し、worktree を追加したときだけ web-<slug> コンテナと https://<slug>.app.dev.local を増やします。つまり、既存の URL の挙動は変えずに、確認先だけを増やせるようにしています。

ここで HTTPS を採用しているのは、ローカル開発一般に HTTPS が必須だからではありません。既存のローカル開発環境が HTTPS 前提で構成されており、URL 生成や cookie、ログイン周りの挙動もそれに沿っていたため、worktree 側も同じ前提に揃えています。

前提として、リバースプロキシは既存のコンテナと同じ Docker ネットワークに参加しており、nginx は Docker の内部 DNS を使って upstream を名前解決します。既存のリバースプロキシ設定でも web:443 を upstream にしているため、worktree 側も同じ形で web-<slug>:443 を参照しました。

ベースの nginx 設定では proxy/conf.d/* を読む構成になっているため、worktree-<slug>.conf を同じディレクトリへ追加すればそのまま読み込まれます。

http {
include /etc/nginx/conf.d/*.conf;
}

各 worktree に対して https://<slug>.app.dev.local のようなドメインを割り当て、リバースプロキシが対象コンテナに振り分けるようにしました。

upstream upstream_worktree_feature_x {
zone worktree_feature_x_dynamic 64k;
server web-feature-x:80 resolve;
}
server {
server_name feature-x.app.dev.local;
listen 443 ssl;
location / {
proxy_pass http://upstream_worktree_feature_x;
}
}

名前解決は完全自動ではなく、ローカルの /etc/hosts127.0.0.1 <slug>.app.dev.local を追加する前提にしています。worktree の追加時にスクリプトから追記コマンドを表示し、リバースプロキシ側ではそのホスト名に対応する TLS 証明書と nginx 設定を生成する形です。

通常環境では、たとえば次のようなエントリを /etc/hosts に持っています。

127.0.0.1 app.dev.local
127.0.0.1 <サービスAのサブドメイン>
127.0.0.1 <サービスBのサブドメイン>
...

worktree を追加した場合は、これに加えて次のような 1 行を手動で足します。

127.0.0.1 feature-add-preview-page.app.dev.local

add 実行時には、スクリプトがそのまま貼り付けられる追記コマンドを表示します。

証明書の生成には mkcert を使っています。既存のローカル開発用証明書も mkcert で生成しており、worktree 用ドメインも同じ方式で <slug>.app.dev.local ごとに個別証明書を発行しています。

GitHub
GitHub - FiloSottile/mkcert: A simple zero-config tool to make locally trusted development certifica... A simple zero-config tool to make locally trusted development certificates with any names you'd like. - FiloSottile/mkcert

初回セットアップでは、既存開発環境と同じく次の流れです。

mkcert -install
mkcert \\
-cert-file "certs/${slug}.app.dev.local.crt" \\
-key-file "keys/${slug}.app.dev.local.key" \\
"${slug}.app.dev.local"

実装上も、worktree 追加時に上記と同じ形で certs/<domain>.crtkeys/<domain>.key を生成しています。

ここでも完全自動化より安全性を優先しています。ホスト OS 側の共有設定をスクリプトが直接消さないことで、既存設定を壊すリスクを抑えています。

これにより、単にポート番号で見分けるのではなく、普段の app.dev.local に近い手順のまま確認できます。

不要になった環境は、bin/worktree.sh rm <slug> で削除できます。このコマンドでは worktree 本体に加えて、その worktree 用のアプリケーションコンテナやリバースプロキシ向けの動的設定ファイルもまとめて片付けます。一方で、/etc/hosts に追記した 127.0.0.1 <slug>.app.dev.local はホスト OS 側の設定なので自動削除の対象外です。

運用してみて良かった点

この構成を導入してから、レビューや比較確認のたびに作業中ブランチを切り替える必要がなくなりました。

たとえば、作業中ブランチを保持したまま main と修正ブランチを別 URL で開き、UI や挙動を見比べながら確認できます。git switch を繰り返していたときよりも、確認のたびに思考が途切れにくくなりました。

また、worktree ごとに Compose プロジェクトを分けているため、コンテナ名の衝突を避けながら並行検証しやすくなりました。URL にブランチごとの slug が入るため、いまどの環境を見ているかも把握しやすくなりました。

成功状態の確認方法

起動に成功すると、bin/worktree.sh は次の情報を表示します。

  • https://<slug>.app.dev.local
  • 直接アクセス用の https://app.dev.local:<port>
  • 停止コマンド

ブラウザで https://<slug>.app.dev.local を開ければ、リバースプロキシ経由の導線まで含めて成功です。

向いているケース / 向いていないケース

この構成が向いているのは、レビュー時の挙動確認、UI の見比べ、修正前後の比較、別ブランチを一時的に立ち上げて確認したいケースです。

一方で、ブランチごとに大きな DB マイグレーション差分を含む検証や、共有 DB の状態差分がそのまま不都合になるケースには向いていません。

つまずきやすい点

ここまでの構成は、日常的な確認を軽く回すには向いていますが、すべてのケースに万能ではありません。特に共有 DB の状態差分や、ブランチ間で大きく前提が変わる検証では注意が必要です。

再現時につまずきやすいのは、主に次の点です。

  • mkcert が入っていないため worktree 用証明書を作れない
  • /etc/hosts に worktree ドメインを追記していない
  • Compose で指定した外部ネットワーク名が、既存環境の Docker ネットワーク名と一致していない
  • 既存の worktree ディレクトリと slug が衝突している

このあたりは、構成そのものよりも「既存環境の固定値」とずれていると起きやすい問題です。

まだ残っている課題

運用してみて便利になった一方で、共通の依存サービスを前提にしている以上、構成として割り切っている部分もあります。以下は、その中でも今後改善余地がある点です。

  • /etc/hosts の追記と削除はまだ手動で、運用手順をもう少し整理したい。rm 実行後に不要な hosts 行は手動で消す運用で、残っていても直ちに大きな害はありませんが、使っていない worktree が増えたら整理したい
  • 不要になった worktree やコンテナを片付ける運用は、継続的に回しやすい形へ改善余地がある
  • 共有 DB を使う都合上、データ状態が他の検証に影響しないよう注意が必要
  • 初回セットアップや証明書周りは、メンバーによっては少しハードルがある

最短で試す手順

細かな運用や後片付けはひとまず置いて、まずは動作の流れだけ確認したい場合は次の順で試せます。

  1. 通常の開発環境を起動し、app.dev.local が開けることを確認する
  2. mkcert でローカル証明書を使える状態にする
  3. bash bin/worktree.sh add <branch> を実行する
  4. 表示された 127.0.0.1 <slug>.app.dev.local/etc/hosts に追加する
  5. https://<slug>.app.dev.local を開く

これで、通常環境の共有サービスを使いながら、worktree ごとの web コンテナを追加する流れをひと通り確認できます。

参考にした記事

今回の発想の出発点として参考にしたのが、TOKIUM さんの以下の記事です。

ポート競合に悩んでいたタイミングで TOKIUM さんの記事を拝見し、今回の構成を考えるうえで大きなヒントをいただきました。この場を借りて感謝します。

まとめ

今回のポイントは、git worktree を導入したこと自体よりも、既存の Docker Compose ベース開発環境で「何を共有し、何をブランチごとに分けるか」を先に決めたことにあります。

重い依存サービスを共有しつつ、確認したいアプリケーションコンテナだけを worktree ごとに切り替えることで、複数ブランチの確認を日常的に回しやすくできます。

既存のローカル開発体験を大きく変えずに、複数ブランチ確認をしやすくしたい場合の一例として、参考になれば幸いです。

  • URLをコピーしました!

この記事を書いた人

インターンとしてバックエンドを担当しています

目次