マイクロサービスとしても動くGoモノレポをシングルプロセスで動かす

モノレポの進化的アーキテクチャでは、デプロイ粒度とコロケーション——サービスをどこまで同居させて動かすか(1プロセス〜マルチホスト)——を後から変えられることが中心の性質になる。ローカル開発の束ね方はその同じ軸の一点で、全サービス1プロセスのランチャー、ホスト名が正しさに関わる4場面、環境の三段構え、*.localhostの解決の罠まで実測ベースで整理する。

「ローカル開発でも、本番と同じようにサービスをサブドメインで分けるべきか? フロントエンドからのアクセスを本番と完全に同じにするために」—— 検証リポジトリでこの問いに 実測で答えたので、判断の枠組みごと整理しておく。

先に文脈を1つ。このリポジトリは複数のドメインサービスを1つのモノレポで 育てる、進化的アーキテクチャの検証ビルドだ。進化的というのは、 デプロイ粒度(何をひとまとまりとしてデプロイするか)とコロケーション (どこまで同居させて動かすか)——1プロセスに全部載せる〜プロセスを分ける〜 コンテナ〜別ホスト——を、作り直しなしに後から変えられることを中心の性質と して設計している、という意味。いつ分離するかは勘ではなく観測で決める (プール使用率・認可のレイテンシ・イベントの滞留といった昇格シグナルを 最初から測っている)。

この整理には確立した語彙がある。Building Evolutionary Architectures が導入し Software Architecture: The Hard Parts が展開した アーキテクチャ量子 (architecture quantum)——「独立してデプロイ可能で、高い機能的凝集と 静的結合の範囲を持つ最小単位」——だ。ここで言うデプロイ粒度の判断は 「量子の境界をどこに引くか」、コロケーションは「複数の量子を同じ器で 動かすか」に対応する。Hard Parts の要点のひとつは、共有 DB のような 静的結合が、別々にデプロイしているつもりの複数サービスを1つの量子に 融合させること。これは実測済みで、database を跨ぐ外部キーを1本張ると 他サービスのデプロイが止まる——あの実験は 2つの量子が1つに融合した瞬間を観測していたことになる。

狙いを一言で言えばこうなる: マイクロサービスとして即座に展開できる状態を 常に保ったまま、普段はシングルプロセスでも動かせるようにする。よくある 「いつか分割できるように作ったモノリス」とは向きが逆で、分割可能性を 後から獲得するのではなく、分割済みのものを畳んで動かしている。

この文脈があると、冒頭の問いの位置づけが変わる。ローカル開発の 「束ねるか分けるか」は開発体験の小技ではなく、本番が将来動くのと同じ軸を、 ローカルで一番安い側に倒しているだけなのだ。

結論: 既定では束ねる。ホスト名による解決は、それが正しさに関わる 4場面だけのオプションにする。 ローカル環境の設計は「本番との一致」を 一律に追うのではなく、何を束ねても安全で、何を分けないと嘘になるかの 線引きの問題になる。

前提: アプリはホスト名を知らない

検証リポジトリのローカル開発規約は、最初の1行でこう決めている。

アプリケーションは ALB 非依存とする。アプリが知るのは Listen するポートだけであり、 TLS・ホスト名・到達制御はインフラの持ち物。呼び先 URL は環境変数注入、 OIDC issuer は設定値。

コードのどこにもホスト名が現れない。API クライアントは env で注入された base URL を使い、OIDC の issuer も設定値。この作りなら、同じコードが http://localhost:8080 にも http://photo.api.localhost にも本番の URL にも 設定値の差だけで向く。「本番とアクセスを同じにする」は URL の形を 揃えることではなく、URL を知らないコードにすることで達成される。

この前提が、以降の「束ねてよい」の全部を支える。逆にコードに URL が 埋まっているなら、この記事の判断は適用できない(先にそちらを直す)。

プロセスを束ねる: 全サービス1プロセスのランチャー

束ね方の一段目はプロセスだ。検証リポジトリでは photo / gear という 複数のサービスを、開発時は allinone という1つのバイナリで起動する。 実装は約70行で、やっていることはこれだけ:

// サービス1つ = goroutine 1つ。各サービスの「本番と同じ起動関数」を
// ポートだけずらして呼ぶ
var services = []service{
	{Name: "photo", Base: 8080, run: func(ctx context.Context) error {
		cfg := photo.ConfigFromEnv()
		cfg.ExternalAddr, cfg.InternalAddr, cfg.AdminAddr = addrs(8080)
		return photo.Run(ctx, cfg)
	}},
	{Name: "gear", Base: 8090, run: func(ctx context.Context) error { /* 同型 */ }},
}

重要なのは「やっていないこと」のほうで、

これはモノリスなのか

見た目は「1プロセスで全部動く」なのでモノリスに見えるが、結合の観点では違う。 モノリス(モジュラモノリスを含む)は、境界を同一プロセス内の関数呼び出しで 越える構成を指す。allinone は境界がネットワーク(HTTP + 認証)のままで、 共有しているのは OS プロセスという器だけだ。

モノリスallinone
境界の越え方関数呼び出し(同一メモリ空間)HTTP + M2M トークン(loopback 経由)
トランザクション全体で1つにできてしまうサービスごと。跨げない
DB共有しがちサービス別 database・別ユーザー(他へは SELECT 権限も無い)
分離のコスト移行プロジェクト起動コマンドを分けるだけ

つまりこれは「開発時だけ全員を同居させた分散システム」で、 プロセスを分ける・コンテナに分ける・ホストに分けるは全部、 設定値と起動方法の差でしかない。束ねても混ざらない境界に しておくことが、束ねてよいことの条件になっている。

そしてこの軸は本番の進化の軸そのものだ。マイクロサービスへの展開は 「いつかやる移行プロジェクト」ではなく、今日でもできる起動方法の変更として 保たれている。だからこそ最初から分割して運用コストを払う必要がなく、 同居から始めて、昇格シグナルが閾値を越えたものから剥がしていく。ローカルの allinone → コンテナ分離 → (将来の)別ホストは、本番が数ヶ月かけて動く道を、開発者が1日の中で 往復しているのと同じことになる。進化の道が普段から通行可能であることを、 毎日の開発が検証している、とも言える。

URL はどうか: ホスト名が意味を持つのは4場面

プロセスと同じ理屈で、URL も既定では束ねる(全部 localhost のポート直)。 ただしこちらには例外があって、名前そのものが正しさに関わる性質が4つある。

場面何が名前に依存するか
OIDC の issuer 整合トークンの iss と検証側の設定が文字列一致する必要がある
Cookieドメインスコープ、サブドメイン間の共有、Secure / SameSite
CORSsame-origin かどうかはホスト名で決まる
認証リダイレクトOP に登録した redirect URI と実際の URL の一致

共通点は全部「ブラウザと認証が絡む境界」であること。逆に言うと、 この4つに触れない変更でホスト名を気にし始めたら、何かをアプリに 漏らしかけているサインになる。

代表として issuer 整合は実測してある。コンテナ化したアプリに「コンテナから 届く名前」の issuer を与えると、ブラウザ経路で取ったトークン (iss = http://127.0.0.1:8180/...)は署名が正しくても文字列不一致で 401 になる。 リバースプロキシ(Caddy)を置き、auth.localhost という1つの名前を ブラウザにはループバックで、コンテナには DNS エイリアスで同じ場所へ 解決させると、両側から同じ issuer 名になって 200 が通る。

環境は三段。コストは検証したい性質に応じて払う

実測の結果、ローカル環境はこう段階化された。

形アプリホスト名用途
Tier 1(既定)allinone(1プロセス)使わない(ポート直)日常。編集→確認のループが最速
Tier 1.5ホストで go run認証だけ auth.localhost認証を触る期間。rebuild なしで issuer をブラウザと統一
Tier 2全部コンテナフルにサブドメインCookie / CORS / リダイレクトまで含む結合検証

Tier 1.5 は「中間があると便利そう」で作ったのではなく、成立するかを 測ってから追加した。ホストで動くアプリの issuer を auth.localhost に 差し替えるだけ(コード変更ゼロ)で、トークン取得 → JWT 検証(JWKS の取得も Caddy 経由)→ 認可サービス → 一覧まで 200 が通り、旧名で発行したトークンは 401 で拒否される。

要点は、上の段ほど検証できる性質が増える代わりにループが遅くなること。 Tier 2 はアプリがコンテナなので、コードを直すたびに rebuild が要る。 Cookie も CORS も触らない日にこのコストを払う理由はない。

罠: *.localhost の解決は3通りに割れる

ホスト名をオプションで導入すると auth.localhost のような名前を使うことに なるが、この名前は誰が解決するかで答えが変わる。実測結果:

解決する主体挙動
curl 8.xRFC 6761 を自前実装。DNS を無視してループバックへ
macOS のシステムリゾルバ(getaddrinfo)同じく特別扱いでループバックへ(DNS に聞かない)
純 Go リゾルバ(CGO 無効の静的ビルド等)特別扱いなし。/etc/hosts → DNS へ普通に問い合わせ、no such host
コンテナ内の純 Go リゾルバ同じ実装だが、聞く相手が Docker 内蔵 DNS でエイリアスを知っているため解決できる

*.localhost をループバックに解決するのは DNS の機能ではなく、リゾルバ側の 特別扱い(RFC 6761)だからこうなる。実害も踏んだ: コンテナ内から curl で DNS エイリアスの疎通確認をしたら、curl が DNS を無視して自分自身に接続し、 意図と違う 401 が返って誤診しかけた。教訓は「検証はアプリ本体と同じ 解決経路で行う」。ツールで代用するなら IP 直指定 + Host ヘッダにする。

ポートにも同種の罠がある。別プロセスが IPv4 の 127.0.0.1:8080 を先に 持っていても、Go の :8080 は IPv6 側で bind に成功する。生存確認の localhost は ::1 に解決されて自分に当たり「起動成功」に見え、 127.0.0.1 へのリクエストだけ別プロセスに流れる。bind の成功はポートの 占有を意味しない。

常用しないなら、守りはどこに置くか

「使われない経路は静かに腐る。Cookie やリダイレクトこそ普段から動かして おくべきでは」という反論は正しい。ただし「開発者が日常的に踏むから気づく」は、 人間がたまたま踏むことに依存した守りで、踏まなければ何も検査されていない。

恒常的な守りは CI のブラウザ E2E に置く。ログイン → リダイレクト → Cookie セッションの実経路を毎 PR で自動実行すれば、「壊れたら気づく」が 人間の習慣ではなく機械の検査になる。そのうえで、認証まわりを触る週は Tier 1.5 や Tier 2 をその期間の既定にすればいい。恒常的な検査は機械に、 日常の環境選択は触っている対象に合わせて人間が。

まとめ

← Back to all posts