マイクロサービスとしても動く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 { /* 同型 */ }},
}
重要なのは「やっていないこと」のほうで、
- DI を混ぜない: 各サービスは自分の合成ルートを普通に通るので、 DB プールも認証器もサービスごとに別個に組まれる。共有インスタンスは無い
- プロセス内呼び出しの近道を作らない: gear が photo を呼ぶときは、
同じプロセスにいても
localhost:8081への HTTP + M2M トークン。 関数呼び出しに置き換える仕掛けが存在しない - サービスのコードはランチャーを知らない: 依存はランチャー → 各サービスの一方向
これはモノリスなのか
見た目は「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 |
| CORS | same-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.x | RFC 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 をその期間の既定にすればいい。恒常的な検査は機械に、 日常の環境選択は触っている対象に合わせて人間が。
まとめ
- モノレポの進化的アーキテクチャの中心の性質は「デプロイ粒度とコロケーションを 作り直しなしに変えられる」こと。ローカルの束ね方(allinone・ポート直)と本番の分割は 同じ軸の両端で、境界(HTTP・別DB・URL非依存のコード)が混ざらない限り どこにでも置ける
- allinone はモノリスではない。マイクロサービスとして即展開可能な状態の まま、シングルプロセスに畳んで動かしているだけ(境界は関数呼び出しに 縮退していない)
- ホスト名が正しさに関わるのは issuer・Cookie・CORS・リダイレクトの4場面 だけ。そのときだけ名前付きの環境をオプションとして重ねる
*.localhostは解決する主体で答えが3通りに割れる。検証はアプリと同じ経路で- 恒常的な守りは人間の習慣ではなく CI の E2E に置く