はじめに
この記事では、複数のサービスやワークロードにまたがって共通ロジックが重複していく問題と、その設計上の対処を整理します。
Go でサービスをデプロイ単位ごとに分けて管理している現場を前提にします。Kubernetes を使っているかどうかに関わらず、同じ問題が起きます。
デプロイ単位を分けること自体のメリットとコストはここでは扱いません。分けることを選んだ上で、共通ロジックをどう扱うかに絞ります。
問題の構造
サービスやワークロードをデプロイ単位ごとに分けると、次のような構成になりやすいです。
service-a/
internal/
logic/
validation.go ← 共通したいロジックがここにある
handler.go
service-b/
internal/
logic/
validation.go ← 同じロジックをコピーして持っている
handler.go
Go の internal パッケージは、internal を含む親ディレクトリ配下のコードからしか import できません。そのため、service-a 配下に閉じた internal のコードを service-b から直接使うことはできません。
モジュール境界も同様に分割を促進します。サービスごとに go.mod が分かれていると、別モジュールのコードを参照するにはバージョン管理が必要になります。ローカル開発では replace ディレクティブで回避できますが、手間が増えるため、コピーの方が手軽に見えてしまいます。
ここで起きているのは Kubernetes 固有の問題ではありません。デプロイ単位ごとにコード境界を分けたときに起きる問題であり、Kubernetes はそれを顕在化させやすい構成というだけです。
Pod 境界と Go のパッケージ境界は別物
ここで混同しやすいのが、Kubernetes の Pod 境界と Go のパッケージ境界です。
Pod は実行単位です。Go のパッケージはコンパイル時のコード構成単位です。そのため、別 Pod にあるコードを Go の import で参照することはできません。
別 Pod の機能を使いたい場合、選択肢は次の2つです。
- 共通ライブラリとして切り出し、各サービスがビルド時に依存する
- 共通サービスとして切り出し、HTTP や gRPC で呼び出す
重要なのは、コード共有と実行分離は別の設計問題だということです。
バリデーションや変換処理のような副作用のないロジックは、まず共通ライブラリとして切り出す方が自然です。逆に、状態を一元管理したい処理や外部リソースへのアクセスを伴う処理は、共通サービスとして切り出す余地があります。
「別 Pod に置けば共通化できる」と考えると、ネットワーク呼び出し、障害点、デプロイ調整といった別のコストを増やしてしまいます。どのロジックをライブラリとして共有し、どこから先をサービス境界として分けるかを分けて考える必要があります。
なぜコピーが生まれるのか
最初は問題になりません。
サービスが1つか2つのうちは、共通化の必要性を感じにくいです。個別に動けば十分で、共有に伴うコストも発生しません。
問題が顕在化するのは、次の状態が重なったときです。
- 同じロジックを複数のサービスが持つようになる
- そのロジックにバグが見つかる
- 修正を全サービスに反映する必要が出る
コピーが増えると、バグ修正のたびに複数のサービスを更新する必要が生まれます。サービス単位でリリース管理をしている現場では、修正漏れや調整コストがそのまま積み上がります。
特に、Pod ごとに個別リリースしていて、手動デプロイや QA 待ちが重い現場ではこの傾向が強くなります。共通ロジックを直すことが「複数サービスに影響する重い変更」に見えるため、短期的にはコピーの方が安全に見えてしまいます。
問題の根本は Kubernetes でも internal でもなく、「共有パッケージに置くという設計判断をしなかった」ことです。境界を先に引いてから、共有手段を後から考えると詰まりやすくなります。
解決の方向性
共通ロジックを、どのサービスにも属さない場所に切り出します。主な選択肢は次のとおりです。
同一リポジトリ内の共有パッケージに置く
モノレポ構成であれば、internal の外に共有パッケージを用意するだけで済みます。
shared/
validation/
validation.go ← どのサービスからも import できる
service-a/
handler.go
service-b/
handler.go
追加のモジュール管理が不要で、最も手軽です。
独立した Go モジュールとして切り出す
リポジトリが分かれている場合や、バージョン管理が必要な場合は独立モジュールにします。
shared/ ← 独立モジュール
go.mod: github.com/org/shared
validation/
validation.go
service-a/
go.mod: require github.com/org/shared v1.0.0
service-b/
go.mod: require github.com/org/shared v1.0.0
モノレポ内でも replace ディレクティブでローカル参照できます。
// service-a/go.mod
require github.com/org/shared v1.0.0
replace github.com/org/shared => ../shared
共通ロジックの修正は shared 側だけ行い、各サービスは依存バージョンを上げて再ビルドするだけで済みます。
運用コストとトレードオフ
独立モジュール化には次のコストが伴います。
-
sharedのバージョン管理が必要になる - 破壊的変更が入ると、各サービスのバージョンアップを順に行う作業が発生する
- サービスごとに
sharedのバージョンが異なる状態が生まれうる
同一リポジトリの共有パッケージであれば、このコストはほぼ発生しません。逆にリポジトリが分かれていると、バージョン管理を避けて通れません。
いずれの場合も、コピーが増え続けるコストと比較して判断する必要があります。
共通化すべきかの判断軸
すべての共通ロジックを切り出す必要はありません。次の条件を目安にします。
共通化する価値がある場合:
- 同じロジックが3つ以上のサービスに存在する
- バグ修正を全サービスに反映する必要がある
- ロジックが安定していて頻繁に変わらない
コピーを許容してよい場合:
- 2つのサービスだけで使うロジックで変更頻度が高い
- サービスごとに微妙な差異があり、共通化すると条件分岐が増える
- 切り出しコストが修正コストを上回る
コピーを「悪」として一律に排除するより、コストを比較して判断する方が現実的です。
共通化に向いているロジックの性質
切り出すロジックの性質も重要です。
共通ライブラリに向いているのは次のものです。
- バリデーション
- 計算・変換処理
- 状態遷移の判定
- 型定義・定数
- エンティティ定義(複数サービスが同じDBを参照している場合)
これらは副作用がなく、どのサービスから呼び出しても同じ結果が返る純粋な処理です。エンティティ定義も、同一DBを複数サービスが参照している構成であれば共通パッケージに置くのが自然です。
向いていないのは次のものです。
- 特定のサービスの状態に依存する処理
- 副作用が多いもの
特定の状態や外部リソースに依存する処理を共通化しようとすると、ライブラリが特定の実装や設定に縛られます。そういった処理は共通化より、サービス内に閉じておく方が扱いやすいです。
まとめ
問題の構造を整理すると次のとおりです。
- デプロイ単位を分けたコード境界 × Go の
internal制約 → コピーが生まれやすい - 問題の本質は Kubernetes でも
internalでもなく、共有の設計判断をしなかったこと - コピーが増えると、バグ修正のたびに複数サービスの更新が必要になる
- 解決策は同一リポジトリの共有パッケージか、独立モジュール化かをコストで選ぶこと
設計として美しいかどうかより、修正がどこに波及するかを意識することが重要です。