dip × Traefik で git worktree での並列開発を「設定ゼロ」にする
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 ひとつにアプリ一式が丸ごと分離された開発環境が手に入ります。
DjangoのSECRET_KEY_FALLBACKSを使ってSECRET_KEYをローテーションする
Djangoの小ネタです。
SECRET_KEY に入れた値などを変更したい場合、素直に SECRET_KEY の値を差し替えてしまうとユーザのログイン情報やセッションの値がクリアされてしまい、利用するユーザに影響が出てしまいます。
この対応として Django 4.1 から settings.SECRET_KEY_FALLBACKS が導入されました。
https://docs.djangoproject.com/ja/4.1/ref/settings/#std-setting-SECRET_KEY_FALLBACKS
settings.SECRET_KEY_FALLBACKS にはリストが入ります。名前の通りSECRET_KEY を使ったチェックのフォールバックとして機能するため、ユーザへの影響を小さく留めることができます。
なお、本記事の動作確認は Django 6.0.2 で行っています。
使い方
- 新しい
SECRET_KEYを設定し、古いSECRET_KEYの値をSECRET_KEY_FALLBACKSに導入する SESSION_COOKIE_AGE(デフォルト: 2週間),PASSWORD_RESET_TIMEOUT(デフォルト: 3日)のうち、長いほうの期間が経過するまでSECRET_KEY_FALLBACKSを維持する- 期間が過ぎたことを確認してから、
SECRET_KEY_FALLBACKSを空にする(または削除する)
※ django.core.signing で有効期限なしの署名を使っている場合、SECRET_KEY_FALLBACKS から削除した時点で BadSignature が発生します。該当箇所がないか事前に確認してください。
注意点
ドキュメントにも記載ありますが、SECRET_KEY_FALLBACKS はリストのため複数の古いキーを保持できます。
検証時はリストの先頭から順にキーを試すため、値が多いほどオーバーヘッドが増加します。古いキーは期間が過ぎ次第、速やかに削除してください。
再考 Choose boring Technology
Shima Tech Hub #15 - connpass でLTするつもりが、急な予定でできなくなったのでその供養として書きます。
Choose boring Technology とは
Dan McKinley さんが 2015年に書いた内容です。自身のEtsyでの経験を元に、(主に企業での)技術の選び方についての考えを述べています。
具体的な経験については先のリンクをみてもらうとして、以下の理由でタイトルの通り「退屈な技術(Boring Technology)を選ぼう」ということを指摘しています。
「イノベーショントークン ≒ 変わったことや新しいこと、難しいことを行うために使えるもの」は3つ(ぐらい少ない)。事業活動にトークンは使ってしまうため技術選択で無駄にしてはいけない
新技術は「知らない問題」( Unkown Unkown )が多すぎる。枯れた技術は失敗モードが既知(Known Unkown)が多い
技術の追加は簡単、運用し続けるのは大変である
似たような話として、「枯れた技術の水平思考」という言葉があります。
よくあるChoose boring Technologyの技術として挙げられるのはDjangoです。
そのDjangoのカンファレンス(DjangoConUS 2025)のキーノートが「枯れた技術の水平思考」でした。 とても面白いのでよかったら観てみてください!
LLMとBoring Technology
さて、ここからが本題です。
2015年に提唱されたこの考え方を、なぜ2025年の今、改めて推したいのか。 答えは、LLMという強力なスキル増幅器が登場したからです。 LLMは、私たちのスキルを増幅してくれます。コードを書くスピードが上がる。知らないことを素早く学べる。デバッグが速くなる。
でも、この増幅器の効果は、すべての技術で同じではありません。技術選択によって、増幅効果が大きく変わります。 そして、Choose Boring Technologyという考え方が、この増幅効果を最大化するんじゃないかと思っています。
ご存知のとおり、LLMは過去の知識が豊富です。特に枯れた技術については、膨大な学習データがあります。
先の例に出したDjangoには20年の知識がインターネットに転がっています。また「やりたいこと」と「書き方」のマッピングが安定しています。
Djangoの枯れ具合や変わらなさとLLMが自身のスキルを増幅する感覚があります。
LLMの情報を発信している simon willsonのブログでも、プログラムにおけるハルシネーションの減らすtipsとして
Chose boring technology. I genuinely find myself picking libraries that have been around for a while partly because that way it’s much more likely that LLMs will be able to use them.
LLMの仕組みを考えれば「当たり前だろ」という感じですが、技術を選ぶ理由として「退屈な技術」が選択されるケースは今後も増えていく(現になっている)と思っています。
LLMという増幅器を手に入れた今、「枯れた技術」の価値を、改めて見直してみてもいいんじゃないでしょうか。
最高!Choose boring Technology
.NETのgitignoreは dotnet new gitignore で生成できる
タイトルのとおりです。
.NETにおける一般的な.gitignore ファイルは dotnet new gitignore で生成できます
生成される .gitignore のテンプレートは
にあります。
参考
miseで.NET 10がインストールできない
日本時間の2025-11-12に.NET 10がリリースされました 🎉
最近 .NET(というかASP.net)を勉強しています。 バージョン管理はmiseで行っています。
せっかくなので、「新しい .NET 10 を使いたい!」と思ったところ表題の問題が発生しました。
起きた問題
mise use dotnet@latest を実行しても .NET 10が降ってこなかった。
解決方法
mise use dotnet@10.0.100 と明示的にバージョンを指定することで、 mise ls-remote のリストになくても実行することができました。
なぜか
miseの.NETのインストールスクリプトは mise-plugins/mise-dotnet に移譲しています。
# https://github.com/jdx/mise/blob/fc0d8fe5/registry.toml#L1298-L1300 [tools.dotnet] backends = ["asdf:mise-plugins/mise-dotnet", "vfox:mise-plugins/vfox-dotnet"] description = ".Net"
https://github.com/mise-plugins/mise-dotnet でインストール可能なバージョンは https://raw.githubusercontent.com/hensou/asdf-dotnet/main/versions.txt にて記載されています。
日本時間の2025-11-12時点では、ここに.NET 10のバージョンである 10.0.100 が記載されていないため、 mise use dotnet@latest しても .NET 10がインストールされません。
ただしmise-plugins/mise-dotnet のコードをみると、指定したバージョンを渡してあげればインストール してくれます。
今回はそれを活用し明示的にバージョンを指定することで解決しました。
.NET 10 のバージョンが 10.0.100 であることは dotnet.microsoft.com
をみて判断しています。
その判断方法は github.com
で見つけました
余談
.NETのインストール対象リストである https://raw.githubusercontent.com/hensou/asdf-dotnet/main/versions.txt は自動で更新されている模様 なので、この対処法もすぐに不要になるかと思います。
django-import-exportでリレーションモデルのフィールドが管理画面上のインポートプレビュー画面に表示されない
発生したこと
django-import-export で、対象のモデル( Example ) に関連付けに必要な列( hoge_id )がプレビューで表示されていない問題がありました。
原因
hoge_id は、対象モデルがもつ実際のフィールドではなく、インポート処理でのみ使用するフィールドでした。
かつリレーションもdjango.db.models.ForeignKey で辿れず、より深いものだったので import_export.widgets.ForeignKeyWidget を指定するだけでは実現できませんでした。
行ったこと
1. import_export.fields.Fieldのattributeに一時的な属性を付与
_hoge_id_import という一時的なフィールドをattributesに用意し、CSVインポートの値をそこに格納するようにしました。
from import_export import fields, resources class ExampleResource(resources.ModelResource): hoge_id = fields.Field( column_name="hoge_id", attribute="_hoge_id_import", )
2. プレビュー画面での表示
Meta.fields に含めてプレビュー画面の対象とする
class Meta: model = Example fields = ( # ... 他のフィールド "hoge_id", )
3. 一時属性の削除
import_instance メソッドをオーバーライドして、保存前に一時属性を削除する
def import_instance(self, instance, row, **kwargs): if hasattr(obj, '_hoge_id_import'): delattr(obj, '_hoge_id_import') return super().import_instance(instance, row, **kwargs)
以上の3つの対応をすることでプレビュー画面に表示することができました。
余談
一時的な属性を付与して削除するやり方がちょっとイケてないなぁと思っています。もっとよいやりかたがあれば知りたいです…
dokku (herokuish buildpack) で GEO/GISのライブラリを入れる
Djangoには django.contrib.gis というパッケージが標準で搭載されています。
この django.contrib.gis を使ったWebアプリケーションを作ろうと思ったのですがその環境を作るのにとてつもなくハマってしまいました…
環境
VPS(Ubuntu 22.04 LTS)にdokkuをインストールしています。今回 dokku apps:create したものにDjangoアプリを載せようとしていました。
ハマったこと
GeoDjangoのドキュメントにあった地理空間ライブラリのインストールに従って、必要なパッケージをインストールしようと思いました。
具体的には https://github.com/heroku/heroku-buildpack-apt.git を buildpackに追加し、 Aptfile を作成。
Aptfile 内に必要なパッケージを記述しました
# Aptfile binutils libproj-dev libatlas-base-dev gdal-bin
起こったこと
デプロイ中に実行する django manage.py collectstatic --no-input が失敗しデプロイできなくなりました
File "/app/.heroku/python/lib/python3.13/site-packages/django/contrib/gis/db/backends/postgis/adapter.py", line 6, in <module>
from django.contrib.gis.geos import GEOSGeometry
File "/app/.heroku/python/lib/python3.13/site-packages/django/contrib/gis/geos/__init__.py", line 6, in <module>
from .collections import ( # NOQA
...<4 lines>...
)
File "/app/.heroku/python/lib/python3.13/site-packages/django/contrib/gis/geos/collections.py", line 7, in <module>
from django.contrib.gis.geos.geometry import GEOSGeometry, LinearGeometryMixin
File "/app/.heroku/python/lib/python3.13/site-packages/django/contrib/gis/geos/geometry.py", line 9, in <module>
from django.contrib.gis import gdal
File "/app/.heroku/python/lib/python3.13/site-packages/django/contrib/gis/gdal/__init__.py", line 29, in <module>
from django.contrib.gis.gdal.datasource import DataSource
File "/app/.heroku/python/lib/python3.13/site-packages/django/contrib/gis/gdal/datasource.py", line 40, in <module>
from django.contrib.gis.gdal.driver import Driver
File "/app/.heroku/python/lib/python3.13/site-packages/django/contrib/gis/gdal/driver.py", line 5, in <module>
from django.contrib.gis.gdal.prototypes import ds as capi
File "/app/.heroku/python/lib/python3.13/site-packages/django/contrib/gis/gdal/prototypes/ds.py", line 10, in <module>
from django.contrib.gis.gdal.libgdal import lgdal
File "/app/.heroku/python/lib/python3.13/site-packages/django/contrib/gis/gdal/libgdal.py", line 71, in <module>
lgdal = CDLL(lib_path)
File "/app/.heroku/python/lib/python3.13/ctypes/__init__.py", line 390, in __init__
self._handle = _dlopen(self._name, mode)
~~~~~~~^^^^^^^^^^^^^^^^^^
OSError: libblas.so.3: cannot open shared object file: No such file or directory
OSError: libblas.so.3: cannot open shared object file: No such file or directory が出てしまっています
解決法
https://github.com/heroku/heroku-buildpack-apt.git のbuildpackでなく、
Herokuが公式に提供している heroku-geo-buildpack を導入することで解決します
起きた理由
エラーでググると同じ症状が
どうも、 heroku-buildpack-apt においてインストールしたパッケージがサブディレクトリに配置されていると検出してくれないようです。
対応のプルリクエストも出ているのですが放置されています。
解決策に提示した https://github.com/heroku/heroku-geo-buildpack ではGEO/GISで必要なライブラリのインストールだけでなくきちんとパスの処理も実行されている ことから問題なく動作します。