Git Worktreeが便利だ。最近はClaude Codeがネイティブに--worktreeオプションを提供するようになったため、単一のリポジトリで同時並行的にAI Agentを活用した開発ができている。
そこで、Git Worktree環境下においてdocker composeの各サービスが開くポートがworktree間で衝突・干渉することを防ぐ、決定論的に振る舞うツールを書いたので紹介する。
使い方
gitリポジトリ下で、まずcompose.yamlを以下のように環境変数を受け取るようにして:
services: web: ports: - "${PORT_WEB}:8080" # サービス名を大文字にしてSCREAMING_SNAKE_CASEにする db: ports: - "${PORT_DB}:5432"
あとはgitリポジトリ、もしくはworktreeの下で:
eval "$(comport)" && docker compose up
を実行するだけで、良い感じにポートが割り当てられる。Direnvが利用可能なら、.envrcでeval "$(comport)"しても良い。
comportは以下のような文字列を出力する:
export PORT_BASE=30588 export PORT_WEB=30588 export PORT_DB=30589
comportは自動的にカレントディレクトリのcompose.yamlとworktreeを認識し、適当な環境変数名とポート番号を割り当てる。ポート番号はサービスが増減しない限り固定であり、PORT_BASEは常に固定だ。
他の利用方法はcomport --helpで得られる。
バイナリリリースはこちらから(Linux/macOS, x86_64/armがあります):
ここからは解説。
ポートの衝突
あるリポジトリにcompose.yamlがあるとする:
services: web: # ... ports: - "8080:8080" db: # ... ports: - "5432:5432"
ここでは、webサービスがホストの8080番を利用し、dbサービスが同様に5432番を利用している。
Git Worktreeを利用していないときはこれで問題ないのだが、Worktreeを利用すると困ったことになる。Worktreeを利用して別々の開発を同一のリポジトリで並行して行う場合、互いにデータや状態などは共有したくないだろうから、独立してdocker composeを起動するのが望ましい。しかしながら、ポートは同じなので衝突してしまう。
つまり、Worktree AとWorktree Bがあるとき、それぞれのcompose.yamlに8080番と5432番を使うサービスが定義されているので、address already in useになってサービスが片方しか起動できない。これだと困る。なんか良い感じに、勝手に譲りあってほしい。
ポートを動的にアサインする
そこで、以下のようなアイデアを考えることができる。
- worktreeのパスなどを使って、worktreeごとにユニークな値を用意する
- ユニークな値のダイジェストを取るなどして、十分に分散したunsigned intを得る
- これを一定の幅(50000など)でmodし、一定範囲内に畳む。この値をベースポート番号とする
- ベースポート番号からサービスの数だけ1ずつインクリメントしてポート番号を割り当てる
- このままだとウェルノンポートに衝突してしまうので、衝突しないように10000あたりを足す
例えば、worktreeのパスからダイジェストを生成し、50000でmodした結果12345になったとする。これをベースポート番号として、webサービスに12345番を、dbサービスに12346番を割り当てればよい。ダイジェストである以上ごくまれな確率で衝突するが、worktreeのパスは変化しないので決定論的に一意なポート番号をそれぞれのサービスにアサインできる。
このアイデアは以下の記事から着想した。
comportでは、ハッシュ関数としてFNV-1a-64を利用している。
ポート情報を渡す
どのサービスにどのポートを与えるかが決まったら、後は実際にdocker composeがこれに従ってホストのポートをコンテナに割り当てるようにすればよい。これを実現するのに好都合なのが環境変数だ。
services: web: ports: - "${PORT_WEB}:8080" db: ports: - "${PORT_DB}:5432"
こうすると外部から注入した環境変数によって動的にポートを割り当てられる。
そこで、あるコマンドを実行したとき以下のような出力が得られればよい:
% some-command export PORT_BASE=30588 export PORT_WEB=30588 export PORT_DB=30589
あとはこれをevalすれば、自動的に環境変数がセットされる:
% eval "$(some-command)"
同時にdocker composeも起動すれば、ワンライナーで全てが完結する:
% eval "$(some-command)" && docker compose up
または、.envrcにeval "$(some-command)"と書けば、全自動で環境変数がセットされる。
この、「あるコマンド」の部分を実装した。それがcomportである。ComposeのPortなのでComportだ。
% comport --help
comport
--base <int> Start of the port range
--dotenv Output in .env format without export
--names <str> Service names (comma-separated). Overrides compose file auto-detection when
specified
--num-services <int> Number of services (used when --names is not specified and no compose file
is found)
--prefix <str> Environment variable prefix
--range <int> Width of the port range
--show Show allocation info on stderr
全ての引数はオプショナルなので、何も付けなくてもうまく動作する。全自動でcompose.yamlを検知して適切な環境変数名を割り当てる。
実装
実装はScala 3で行っている。が、大半の実装はAIに委任している。Scala 3の型システムは非常に強力なので、コンパイルさえすれば期待した通りの動作になる。これがAIと相性が良く、一発で動く実装を用意してくれた。昔はLLMが弱くてヘタクソだったのだが、今や相当強力なパートナーだ。
また、ビルドはScala Nativeを利用してネイティブバイナリにビルドしている。Scala NativeはLLVMベースの技術なので、既存の資産の利用が容易だ。Comportでは、Linux版ビルドでは標準Cライブラリとしてglibcではなくmuslを利用し、静的リンクしてstatic binaryを出力している。macOS版ビルドでは環境の差異を考える必要があまりないので動的リンクしてある。
このmuslビルドが結構大変だった。というのも、GitHub Actionsのubuntu-latestはglibcで動いているのでmuslでうまくリンクできないため、Dockerコンテナ上でAlpineを起動して、その中でビルドする技によってなんとかしている。ビルドに使っているScala CLIのネイティブバイナリはglibcでビルドされているのでAlpine上では動かず、JVM版を呼び出すことで対処した(両方リリースされている)。Scala CLIがAlpineベースのコンテナイメージを用意してくれると楽ができるのだが・・・。
ともかく、Scala Nativeを使ってmusl版のstatic binaryをビルドすることができて嬉しい。実装はシンプルだし、ビルドワークフローは参考になると思うのでぜひ見ていってほしい。