K8sに学ぶ大規模Goプロジェクト設計

[![表紙](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3967563/b5c17830-9822-4a10-8fa7-6965423bea81.png)](https://leapcell.io/?lc_t=q_gok8sarch)

Go で高い拡張性、信頼性、保守性を持つ大規模プロジェクトを構築しようとする前に、まず Kubernetes のプロジェクト構造を通して、どのようにコンテナオーケストレーションという一連の機能モジュールを組織しているのかを見てみましょう。

## Kubernetes のコードレイアウト

以下は、Kubernetes の主なトップレベルディレクトリとその主な機能です。これから、これらのディレクトリがどのような機能を持つかを一つ一つ説明します。

- **api**：インターフェースプロトコルの格納
- **build**：アプリケーションのビルド関連コード
- **cmd**：各アプリケーションの `main` エントリーポイント
- **pkg**：各コンポーネントの主な実装
- **staging**：各コンポーネント間で相互依存するコードの一時保存

### api

OpenAPI や Swagger のファイルが格納されており、JSON や Protocol の定義が含まれています。

### build

Kubernetes プロジェクトのビルドスクリプトが格納されています。K8s 各コンポーネントのビルドや、pause プログラムなど必要なイメージのビルドも含みます。

### cmd

cmd ディレクトリには、プロジェクトでビルドされる実行ファイルの main パッケージソースファイルが格納されています。複数の実行ファイルをビルドする場合は、それぞれを個別のサブディレクトリに配置できます。Kubernetes の cmd ディレクトリ下のサブディレクトリの内容を見てみましょう。

```
- cmd 各アプリケーションの `main` メソッド
    - kube-proxy ネットワーク関連ルールのアプリケーション
    - kube-apiserver k8sのAPIを公開し、リクエストの処理を担当。さまざまなリソース（Pod, replicaSet, Service）のCRUDを提供
    - kube-controller-manager
    - kube-scheduler 新しく作成されたPodを監視し、実行ノードを選択
    - kubectl クラスターにアクセスするコマンドラインツール
```

このディレクトリの下には、k8s でよく知られている kube-proxy や kube-apiserver のコンポーネントがここにあることが分かります。

### pkg

pkg ディレクトリには、プロジェクト自身が使用する依存パッケージやエクスポートされるパッケージが存在します。

```
- pkg 各コンポーネントの主な実装
    - proxy： ネットワークプロキシの実装
    - kubelet： ノード上のPodの管理
        - cm: コンテナ管理、cgroupsなど
        - stats ：リソース使用状況、`cAdvisor`により実装
    - scheduler： Podスケジューリングの実装
        - framework
    - controlplane：コントロールプレーン
        - apiserver
```

### staging

staging ディレクトリのパッケージは、ソフトリンクの形で k8s.io 内に接続されています。まず、Kubernetes プロジェクトは非常に大規模なため、リポジトリが分散されることによる開発障害を避け、すべてのコードを 1 つの pull request で提出・レビューできるようにしています。この方法で、モジュールの独立性を保ちながら、メインリポジトリのコードの完全性も保証できます。

また、go mod の replace を利用することで、各依存コードごとに tag を付ける必要がなく、バージョン管理とリリースプロセスが簡素化されます。

### Standard Go Project Layout との比較

internal ディレクトリは、外部から利用されたくないパッケージを格納するためのものです。Go では、internal ディレクトリに格納されたパッケージは、自身のプロジェクト内でのみ利用でき、外部プロジェクトからは見えなくなります。

しかし、k8s には internal ディレクトリが存在しません。その理由は、Kubernetes プロジェクトは 2014 年ごろに開発が始まりましたが、internal ディレクトリの概念は Go 1.4（2014 年末リリース）以降に導入されたためです。Kubernetes プロジェクトの初期開発段階では、internal ディレクトリの設計慣例がまだ定着しておらず、その後も大規模なリファクタリングによって導入されることはありませんでした。

また、Kubernetes の設計目標の 1 つはモジュール化と疎結合であり、明確なパッケージ構成とコード構造によってカプセル化を実現しているため、internal パッケージによるアクセス制限を必要としません。

ここまでで、プロジェクトを構築するための標準的なトップレベルディレクトリ構造について理解できたと思います。

Go には Java のような標準的なディレクトリ構成がなく、これはプロジェクトごとに異なるコード構造に慣れる必要があるという問題を生み出します。同じチーム内でさえも構造が異なることがあり、新人がプロジェクトを理解する上で大きな障害となります。

こうした障害があると、協力もしにくくなります。統一されたトップレベルディレクトリ構成があれば、新しいプロジェクトを担当する際に、統一された入り口から素早くコードを見つけられるようになり、協力開発時の効率が上がり、コードの場所に悩むことが減ります。

しかし、トップレベルディレクトリの構造を統一するだけで、完璧な大規模プロジェクトを構築できるでしょうか？答えは明確に「ノー」です。

単にディレクトリ構成を統一するだけでは、コードが次第に腐敗して混乱していく問題を根本的に解決できません。良い設計理念こそが、プロジェクトが肥大化する中でもソフトウェア設計の筋道を明確に保つ鍵となります。

## 宣言的な理念設計

宣言的 API は、Kubernetes の全体的なコード設計を貫いており、手続き型プログラミングに陥ることを防いでいます。

例えばリソースの状態を変更する際には、「k8s に望ましい状態を伝えるべき」であり、「k8s にどう処理させるかを細かく指示するべきではありません」。これは kublet の rolling-update が廃止された理由でもあり、その設計は Pod 更新の一連の動作を細かく管理していました。

Kubernetes に望ましい状態を伝えることで、kubelet がその期待値に従い動作し、外部は余計な介入をせずに済みます。

ここまで読んで、疑問に思うかもしれません。「宣言的 API はなぜ、プロジェクトが大規模化してもモジュールの明確さを保てるのか？」これはユーザーが Kubernetes を使う際に体感することではないか？なぜ内部設計と関係あるのか？

インターフェースを設計する際、操作プロセスを全てユーザーに公開し、一歩一歩手動で Pod の更新などに関与させるなら、その設計モジュールは手続き型にならざるを得ません。そうなると、コードモジュールは明確にならず、多くのユーザー操作プロセスと密結合してしまいます。

宣言的 API により、k8s に望ましい状態だけを伝えれば、クラスター内部で複数のコンポーネントが協調し最終的にその状態を実現できます。ユーザーは内部の更新プロセスを知る必要がなく、新たな連携プラグインを追加する場合も、API を追加で公開せず、モジュール追加だけで済みます。

cAdvisor は k8s でデプロイされたリソースを監視し、コンテナリソースの指標を収集します。それは独立して動作し、外部コンポーネントに依存しません。次に、コントローラーがこれらの指標とユーザーが宣言した指標を比較し、スケールアウト/インの条件を満たしているか判断します。

モジュールが独立しているため、cAdvisor は指標を収集して返すことに集中できます。それが観測用か自動スケーリングのためか、用途を気にする必要はありません。

このように異なるタスクのコンポーネントを設計する際の原則として、「必要な要件を明確にし、情報伝達時には入力と出力に集中し、内部実装は凝集させて外部に漏らさず、外部からの利用をシンプルに保つ」ことが重要です。

## 過剰設計の回避

過度なエンジニアリング設計は、不十分な設計よりも有害であることが多いです。

最初期の Kubernetes バージョン 0.4 では、ネットワーク部分の公式実装は GCE 上で salt スクリプトを実行して bridge を作成する方法でした。他の環境では Flannel や OVS が推奨されていました。

Kubernetes の発展に伴い、Flannel は一部の状況で十分でなくなり、2015 年ごろには Calico や Weave などが登場し、ネットワーク問題がほぼ解決されました。これにより Kubernetes 自身がネットワークに多くの労力をかける必要がなくなり、CNI が登場してネットワークプラグインの標準化が進みました。

Kubernetes は最初から完成された設計だったわけではなく、問題の発生に応じて新たな設計を導入し、様々な環境変化に適応してきたのです。

プロジェクトを最初に立ち上げた時は、**依存関係は比較的明確です。そのため、設計初期には循環依存のようなシナリオには直面しません。**しかし、プロジェクトの進展とともに、こうした問題が徐々に現れてきます。プロダクト要件で機能が増えると、コード設計上のクロスリファレンス（相互参照）が発生しやすくなります。

事前にできるだけ業務の背景や解決すべき問題を理解しようとしても、プロダクト機能の変化やプログラムの進化とともに、新たな課題が生まれることもあります。設計時には、モジュール設計と依存関係の整理に注意し、現有機能の凝集度を高めておくことで、今後の抽象化や拡張時に、以前のすべてのコードを書き換えずに済むようにし、「リファクタリング」的な大きな修正が不要になるようにしておきます。

「拡張性」のためだけに、プログラムの拡張設計を過剰に行い、「設計のための設計」をしてしまうのは避けるべきです。

次に、EC ビジネスの設計シナリオを通じて、設計の進化を見てみましょう。

まずシステムには 2 つのモジュールがあります：

- オーダーモジュール：注文の作成、支払い、状態更新などを担当します。ユーザーモジュールに依存してユーザー情報（配送先住所、連絡先など）を取得します。
- ユーザーモジュール：ユーザー情報の管理、登録、ログイン、ユーザーデータの保存を担当します。注文モジュールには依存しません。

この初期設計では依存関係は一方向です：注文モジュールがユーザーモジュールに依存しています。

この段階で無理に抽象設計をする必要はありません。多くのプロジェクトは最初から成功が約束されているわけではないため、過度な設計に時間を費やす余裕はなく、プロダクトの大きな方針転換があれば、過剰な設計は後々の修正の障害にもなりかねません。

やがて要件が発展し、次の新たな要件が生じます：プラットフォームがユーザーの購買履歴（注文履歴）に基づいて、個別のおすすめ商品を提示したい。

この個別おすすめを実現するために、ユーザーモジュールは注文モジュールのインターフェースを呼び出し、ユーザーの注文履歴を取得する必要が出てきました。

こうして依存関係は次のようになります：

- 注文モジュールはユーザーモジュールからユーザー情報を取得します。
- ユーザーモジュールは注文モジュールから注文履歴を取得します。

この変化によって循環依存が発生しました。注文モジュールがユーザーモジュールに依存し、同時にユーザーモジュールも注文モジュールに依存している状況です。

循環依存を解決するために、次のような方法が考えられます：

モジュールの責務を分離する：新たに「おすすめモジュール」を導入し、個別おすすめのロジック専用に処理を行わせます。おすすめモジュールはユーザーモジュールと注文モジュールからそれぞれ必要なデータを取得し、両者の直接的な相互依存を避けます。

こうして、モジュールの抽出によりユーザーモジュールと注文モジュールの結合問題を解消できました。

しかし、ここで新たな要件が発生しました。ユーザーがキャンペーン期間中にキャンペーン商品を購入した場合、プロダクトマネージャーは、こうした注文が発生したことを検知し、おすすめモジュールが即座に把握して、関連するキャンペーン商品のおすすめを行いたいと考えました。たとえば、ユーザーが割引価格のスポーツウォッチを購入した際に、同時に割引価格の Bluetooth スポーツイヤホンをおすすめすれば、ユーザーの再購入率がさらに高まるのではないでしょうか？

この時、注文が発生した後に注文モジュールがおすすめモジュールにデータを渡す、という方法は明らかに適切ではありません。なぜなら、もともとおすすめモジュールは注文モジュールからユーザーの購入データを取得するという一方向の依存関係があるため、ここで注文モジュールがおすすめモジュールを呼び出すと、循環依存が再び発生してしまいます。

では、どのようにしておすすめモジュールが注文の変化を即座に把握できるようにするのでしょうか？この場合、イベントドリブン（イベント駆動）による実現が必要です。

イベントドリブンの方法では、注文モジュールがユーザーの注文時にイベントを発火させ、おすすめモジュールはそのユーザー注文発生イベントを購読します。こうすれば両者は直接的に相手のインターフェースを呼び出す必要がなく、イベントを介してデータを受け渡すことができます。

おすすめモジュールがデータを取得した後、即座に新しいおすすめモデルの再トレーニングを始め、ユーザーに関連商品を提案できます。

この例から分かるように、現在のエンタープライズアプリケーションの大きな難しさの一つは「ビジネス領域モデリング」にあります。

モデリングにおいては、ニーズの進化に応じて継続的に最適化・設計していく過程が求められます。

ここで挙げたユーザー、注文、おすすめモジュールは、ほとんどの TOC（消費者向け）プロダクトの進化過程で直面する内容でもあります。

進化の過程で、私たちがいかにモジュール設計やコード構造を最適化し、反復のスピードを高めていくかは、今後も継続して模索し、考えていかなければならない課題です。

## まとめ

この記事の内容を振り返ってみましょう。

- 大規模プロジェクトを構築する際、統一されたディレクトリ構成は協力効率を高めますが、良い設計理念こそがプロジェクトの明確さや拡張性を維持するカギとなります。
- Kubernetes の宣言的 API は、モジュールの独立性を保ち、手続き型プログラミングの罠を回避するのに役立ちます。
- プロジェクト設計は、実際のニーズに基づいて段階的に進化させ、過剰設計を避けるべきです。
- モジュールの責務や依存関係の合理的な分離に注目し、イベントドリブンを用いてモジュール間の結合問題を解決することが重要です。

---

### [私たちはLeapcell、Goプロジェクトのホスティングの最適解です。](https://leapcell.io/?lc_t=q_gok8sarch)

[![Leapcell](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3967563/ec4a54b8-f48a-4ba9-a782-1b5a60639964.png)](https://leapcell.io/?lc_t=q_gok8sarch)

[Leapcell](https://leapcell.io/?lc_t=q_gok8sarch)は、Webホスティング、非同期タスク、Redis向けの次世代サーバーレスプラットフォームです：

**複数言語サポート**

- Node.js、Python、Go、Rustで開発できます。

**無制限のプロジェクトデプロイ**

- 使用量に応じて料金を支払い、リクエストがなければ料金は発生しません。

**比類のないコスト効率**

- 使用量に応じた支払い、アイドル時間は課金されません。
- 例: $25で6.94Mリクエスト、平均応答時間60ms。

**洗練された開発者体験**

- 直感的なUIで簡単に設定できます。
- 完全自動化されたCI/CDパイプラインとGitOps統合。
- 実行可能なインサイトのためのリアルタイムのメトリクスとログ。

**簡単なスケーラビリティと高パフォーマンス**

- 高い同時実行性を容易に処理するためのオートスケーリング。
- ゼロ運用オーバーヘッド — 構築に集中できます。

[ドキュメントで詳細を確認！](https://docs.leapcell.io/)

[![Try Leapcell](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3967563/7155d7bf-ca32-4fc4-ab29-d96b018e1d2e.png)](https://leapcell.io/?lc_t=q_gok8sarch)

Xでフォローする：[@LeapcellHQ](https://x.com/LeapcellHQ)

---

[ブログでこの記事を読む](https://leapcell.io/blog/learning-large-scale-go-project-architecture-from-kubernetes)