git worktree で複数ブランチを同時に動かそうとすると、必ずポートの取り合いにぶつかります。本記事では、フォルダ名がそのまま URL になり、dip up するだけでいくつでも並列起動できる開発環境を、コピペで動く最小サンプル付きで作ります。鍵は「ポートを公開しない」ことと、それを運用に落とし込む dip の infra ブロックです。
方針:ポートを公開しない(Traefik で名前ルーティング)
アプリのコンテナはホストにポートを一切公開しません。公開するのは Traefik というリバースプロキシが握る :80 だけです。
- Traefik が
:80を1つだけ持ちます - リクエストの
Host:ヘッダを見て、対応するコンテナへ振り分けます - アプリ側は Docker ラベルで「自分は
xxx.localhost宛て」と宣言するだけです
ホストに公開するポートが Traefik の :80 だけなら、アプリを何個並べてもポートの衝突は起きません。
ドメインは *.localhost を使います。*.localhost は RFC 6761 により 127.0.0.1 に解決されることが保証されていて、/etc/hosts の編集も dnsmasq も要りません。anything.localhost はそのままループバックに届きます。
この発想自体は新しくありません。ポートを Traefik で消す手法は madewithlove が、worktree と組み合わせる例は devcontainer ベースの kenfdev の記事 が先行しています。ただ、dip と統合して追加設定も起動忘れもゼロにした例はまだ見かけません。本記事はそこを埋めます。差がつくのは、この Traefik をどこで、どう動かし続けるかという点です。
dip が効く機能
dip は Docker 開発環境のための薄いラッパーで、dip up や dip bash のようにコマンドを定義できます。今回頼りにするのは、次に挙げる dip の機能です。
共有 Traefik を置く場所(infra ブロック)
Traefik はマシンに1個だけ動いていれば十分です。すべての worktree とプロジェクトのリクエストを、1つの Traefik が :80 で受けて振り分けます。
素朴に Traefik をアプリの docker-compose.yml(dip の compose.files)に入れると失敗します。worktree ごとに Traefik が増殖して :80 で衝突します。共有したいものを各プロジェクトに置いてはいけません。
dip にはこのための infra ブロックがあります。インフラは worktree とは独立した置き場で管理します。
# dip.yml infra: traefik: path: .dip/traefik
.dip/traefik/docker-compose.yml には Traefik 単体を書きます。--providers.docker で同じ Docker ネットワーク上のコンテナを自動検出し、ラベルに従ってルーティングします。
# .dip/traefik/docker-compose.yml services: traefik: image: traefik:v3.7.5 restart: unless-stopped command: - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" - "--providers.docker.network=${DIP_INFRA_NETWORK_NAME}" - "--entrypoints.web.address=:80" - "--api.dashboard=true" ports: - "80:80" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro labels: - "traefik.enable=true" - "traefik.http.routers.dashboard.rule=Host(`traefik.localhost`)" - "traefik.http.routers.dashboard.entrypoints=web" - "traefik.http.routers.dashboard.service=api@internal" - "traefik.docker.network=${DIP_INFRA_NETWORK_NAME}" networks: default: name: ${DIP_INFRA_NETWORK_NAME} external: true
dip infra up を1回打てば、この Traefik と共有ネットワークが起動します(冪等で、何度打っても1個のままです)。http://traefik.localhost でダッシュボードが見えます。
起動忘れを防ぐ preflight
「共有 Traefik を先に起動しておく」が前提だと、必ず起動を忘れる人が出ます。dip の preflight はすべての dip コマンドの前に走るフックです。ここに Traefik の冪等起動を仕込んでおけば、dip up を打った時点で勝手に立ち上がります。
# dip.yml preflight: - dip infra up --no-update
--no-update は毎回のイメージ pull を抑止します(起動を速く保つためです)。これで「dip infra up を打ち忘れて network が無くて落ちる」が構造的に起きなくなります。
設定ゼロのスラグ(COMPOSE_PROJECT_NAME の自動導出)
Docker Compose v2 は、COMPOSE_PROJECT_NAME をカレントディレクトリ名から自動導出します。worktree のフォルダ名が my-app-feature-x なら、プロジェクト名も自動で my-app-feature-x になります。
これをそのまま Traefik の Host ルールに流します。
# docker-compose.yml(抜粋) web: labels: - "traefik.enable=true" - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-web.rule=Host(`${COMPOSE_PROJECT_NAME}.localhost`)" - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-web.entrypoints=web" - "traefik.http.services.${COMPOSE_PROJECT_NAME}-web.loadbalancer.server.port=8000"
結果として、次が成り立ちます。
- worktree ごとの
override.yml生成は不要 - ブランチ名を渡す引数も不要
.envの書き換えも不要
フォルダを切って dip up するだけで、<フォルダ名>.localhost が生えます。ルーター名にもプロジェクト名を含めているので、複数 worktree のルーターが Traefik 上で名前衝突することもありません。
採番方式や専用ツールとの差はここです。追加で管理するもの(番号、生成ファイル、ツール)が何も増えません。
ネットワーク注入と生 compose 用のフォールバック
アプリのコンテナは、Traefik と同じ外部ネットワークに乗らないと検出されません。dip はこの共有ネットワーク名を環境変数として全 compose コマンドに注入してくれます(DIP_INFRA_NETWORK_TRAEFIK)。
ただし dip を経由せず生の docker compose を直接叩くと、この注入は効きません。そのため compose 側にデフォルト値(:-)を書いておくと、どちらの経路でも壊れません。
# docker-compose.yml(抜粋) networks: default: traefik: external: true name: ${DIP_INFRA_NETWORK_TRAEFIK:-dip-net-traefik-latest}
最小サンプルで動かす
実際に動かせる最小構成を載せます。web(Flask)+ db(Postgres)+ 共有 Traefik の3点です。フォルダ構成は次のとおりです。
dip-traefik-worktree-demo/ ├─ dip.yml ├─ docker-compose.yml ├─ .dip/traefik/ │ └─ docker-compose.yml # 上記で掲載済み ├─ Dockerfile └─ app/ └─ server.py
dip.yml
version: '7.1' compose: files: - docker-compose.yml infra: traefik: path: .dip/traefik # 全 dip コマンドの前に共有 Traefik を冪等起動する(起動忘れ防止) preflight: - dip infra up --no-update interaction: psql: description: Postgres console service: db command: psql -U postgres -d app
docker-compose.yml
services: web: build: . environment: DATABASE_URL: "postgres://postgres:postgres@db:5432/app" COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-} networks: - default - traefik labels: - "traefik.enable=true" - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-web.rule=Host(`${COMPOSE_PROJECT_NAME}.localhost`)" - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-web.entrypoints=web" - "traefik.http.services.${COMPOSE_PROJECT_NAME}-web.loadbalancer.server.port=8000" - "traefik.docker.network=${DIP_INFRA_NETWORK_TRAEFIK:-dip-net-traefik-latest}" depends_on: db: condition: service_healthy db: image: postgres:16 environment: POSTGRES_DB: app POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 2s timeout: 3s retries: 30 networks: default: traefik: external: true name: ${DIP_INFRA_NETWORK_TRAEFIK:-dip-net-traefik-latest} volumes: pgdata:
ポイントは、db には Traefik のラベルを付けず、traefik ネットワークにも繋がないことです。DB は外から名前で触る必要がないので、ルーティング対象から外します。「公開したいものだけを網に乗せる」という線引きです。
もう1点、web は depends_on の condition: service_healthy で DB の起動完了(pg_isready)を待ちます。これがないと web が DB 起動前に接続して落ち、Traefik からはバックエンド不在で 404 に見えます。実際、このサンプルを作る過程で踏んだ落とし穴です。
Dockerfile
FROM python:3.13-slim WORKDIR /app RUN pip install --no-cache-dir flask "psycopg[binary]" COPY app/ /app/ CMD ["python", "server.py"]
app/server.py は、自分のホスト名、プロジェクト名、接続中の DB、「この worktree でのアクセス回数」を返します。カウンタは DB に持たせるので、worktree ごとにデータが分かれていれば値も分かれます。
import os import socket import psycopg from flask import Flask app = Flask(__name__) DB = os.environ["DATABASE_URL"] def init_db(): with psycopg.connect(DB) as conn: conn.execute("CREATE TABLE IF NOT EXISTS hits (n int)") if conn.execute("SELECT count(*) FROM hits").fetchone()[0] == 0: conn.execute("INSERT INTO hits (n) VALUES (0)") @app.route("/") def index(): with psycopg.connect(DB) as conn: conn.execute("UPDATE hits SET n = n + 1") n = conn.execute("SELECT n FROM hits").fetchone()[0] db = conn.execute("SELECT current_database()").fetchone()[0] return ( f"container hostname: {socket.gethostname()}\n" f"COMPOSE_PROJECT_NAME: {os.environ.get('COMPOSE_PROJECT_NAME', '(unset)')}\n" f"database: {db}\n" f"hits (this worktree): {n}\n" ), 200, {"Content-Type": "text/plain; charset=utf-8"} if __name__ == "__main__": init_db() app.run(host="0.0.0.0", port=8000)
起動して並列にする
$ dip infra up # 共有 Traefik(マシンに1回・冪等) $ dip up -d # このフォルダのアプリ起動
スラグはフォルダ名なので、http://dip-traefik-worktree-demo.localhost で開きます。
$ curl -s http://dip-traefik-worktree-demo.localhost/ container hostname: fc00271efa47 COMPOSE_PROJECT_NAME: dip-traefik-worktree-demo database: app hits (this worktree): 1
ここで worktree をもう1つ切ります。ポート設定は一切触りません。
$ git worktree add ../demo-feature-x $ cd ../demo-feature-x $ dip up -d
フォルダ名が demo-feature-x なので、こちらは http://demo-feature-x.localhost で開きます。2つを同時に叩いてみます。
$ curl -s http://dip-traefik-worktree-demo.localhost/ container hostname: fc00271efa47 COMPOSE_PROJECT_NAME: dip-traefik-worktree-demo database: app hits (this worktree): 2 $ curl -s http://demo-feature-x.localhost/ container hostname: 56746f619f55 COMPOSE_PROJECT_NAME: demo-feature-x database: app hits (this worktree): 1
ホスト名が違います(別コンテナです)。そして hits も独立しています。demo 側を2回、feature-x 側を1回叩いたぶんが、それぞれ別々にカウントされています。COMPOSE_PROJECT_NAME ごとに pgdata ボリュームが <プロジェクト名>_pgdata として分かれるので、DB のデータまで worktree ごとに分離されます。
ポートを publish していないことも確認できます。
$ docker compose -p dip-traefik-worktree-demo ps --format '{{.Service}} {{.Ports}}'
web
db 5432/tcp
-> 付きのホストマッピング(0.0.0.0:3000->...)がありません。ホストに口を開けているのは Traefik の :80 だけです。だから worktree を何個並べても衝突しません。
踏みやすい落とし穴
実際に使う前に押さえておきたい3点です。
_(アンダースコア)をスラグに入れない。 フォルダ名(= プロジェクト名 = ホスト名)に_が入ると、DNS のホスト名として不正になりルーティングが壊れます。my_app_wt2のような名前は避け、my-app-wt2のようにダッシュにします。心配なら、起動時にプロジェクト名へ_が含まれていたらエラーで弾くガードを入れておくと事故が減ります。- 共有したいもの以外を網に乗せない。 Traefik 網に繋ぐのは外から名前で触りたいサービスだけです。DB やテストランナーは繋ぎません。サンプルの
dbがまさにその例で、繋がないことで「外から触れない」状態を保てます。 - 生
docker composeではネットワーク名が注入されない。 dip 経由ならDIP_INFRA_NETWORK_TRAEFIKが注入されますが、生のdocker composeを直接叩くと効きません。compose 側に:-dip-net-traefik-latestのデフォルトを書いておけば両対応できます。
まとめ
やったことは次の3点です。
- Traefik を
dip infraに1個だけ置き、preflightで冪等起動する - アプリはポートを公開せず、
COMPOSE_PROJECT_NAME(= フォルダ名)を Traefik ラベルに流す - worktree を切って
dip upするだけで<フォルダ名>.localhostが生え、DB まで分離される
追加で管理する番号も、生成ファイルも、専用ツールも増えません。
なお、この仕組みは web だけでなく、メール UI を mail.<slug>.localhost、オブジェクトストレージを s3.<slug>.localhost、フロントの HMR サーバを assets.<slug>.localhost ……というように、サービスごとにサブドメインを生やす形へ広がります。ラベルを足すだけです。実プロダクトではそうやって、worktree ひとつにアプリ一式が丸ごと分離された開発環境が手に入ります。