K8sに学ぶアーキテクチャ設計

[![表紙](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3967563/e386906c-7d5c-4141-959b-626008c6a981.png)](https://leapcell.io/?lc_t=q_gok8sarchdesign)

## 繰り返しはすぐに排除する必要はない

私たちは、あらゆる重複を直ちに排除しなければならないというストレス反応のパターンに陥らないよう、十分注意する必要があります。これらの排除アクションが、本当の意味での重複に対してのみ行われることを必ず確認しなければなりません。

もし二つのコードが見た目上は重複しているように見えても、それぞれが異なる進化パスを辿り、つまり異なる変更速度や変更理由を持っている場合、それらは本当の重複とは言えません。

今書いているコードが、将来のコードの障害にならないようにすることは非常に重要なスキルであり、通常、習得には何年もの時間がかかります。なぜなら、本当のトラブルはソフトウェアの実行時に現れるのではなく、このソフトウェアシステムの開発・デプロイ・およびその後の追加開発の中で現れることが多いからです。

多くの人は最初、抽象設計が好きで、将来汎用的になるかもしれない内容を抽象化しようとします。しかし、現在の高速なプロダクト開発の中で、多くの内容は最初は似ているように見えても、最終的には異なる進化パスを辿ることがよくあります。その場合、この二つのコードは本当の意味での重複コードではありません。

このような場合、最初に固定的な設計をしてしまうと、その部分のコードが将来の障害となります。もし元のメソッドをできるだけ小さな粒度に分割し、使う人が自分で組み立てるようにすれば、機能が固定されたミドルウェアではその必要はありませんが、一般的なビジネスロジックでは、むしろ良い方法となるかもしれませんし、再利用度も大きな一つの方法より高くなるかもしれません。

大きく万能な機能の中には、必ずと言っていいほど新しい要件には不要な微細な違いが存在します。この時には二つの方法があります。一つは元の実装に if/else を追加して異なるロジックブランチを作る方法、もう一つは最初からその部分をインターフェースにして、異なる実装クラスを注入して実現する方法です。

どちらの方法でも、元のメソッドに手を加えることになります。もしシステムがあまりに複雑かつ老朽化し、メンテナンス担当者が既に交代している場合（これはよくあることです）、この時、変更がどれほどの副作用を生むか分かりません。より安全な方法は、コードのエントリポイントを再定義し、小さな粒度のメソッドを再利用することです。

しかし、この方法にも問題があって、小粒度のメソッドが散在しすぎると、組み立てがやや複雑になる場合もありますが、その利点はよりコントロールしやすいという点です。

Kubernetes でも、多くのセマンティクスが明確なメソッドやインターフェースを見ることができます。このような設計の利点は、後から追加されたメンバーが実装を柔軟に差し替えることができ、元の設計に縛られないことです。こうすることで、全てのロジックを再構築するために多くの労力を費やす必要がなくなり、部分的な変更も容易に行えます。

設計のしすぎには注意してください。コードを書くときには「なぜそうするのか」をよく自分に問いかけ、安定する前にデザインをしないようにしましょう。まずは同じ内容が出てきた時点で抽象化し、できるだけオブジェクトをシンプルに保つようにしましょう。リファクタリングの際に、同じ内容を統合して重複を減らすことができます。

たとえば、CRI（Container Runtime Interface）の抽象化も、Kubernetes が最初から持っていたわけではありません。最初は Kubernetes は DockerManager に強く依存しており、内部では Docker Manager が Docker Engine へ HTTP 経由で指令を送っていました。その後、Docker へのハードコーディングから脱却し、より多くの環境に対応するため、Kubernetes はバージョン 1.5 以降で CRI を導入し、コンテナランタイムが kubelet にどう接続すべきかの標準仕様を定義しました。

しかし、CRI は Docker 以後に登場した仕様のため、Kubernetes は Docker と CRI の間に DockerShim というアダプター層を設け、それが Docker Engine と HTTP 通信を行うようになっています。（DockerShim のソースコードを見ることでアダプターパターンの説明ができます）

最終的にコンテナは containerd という形で存在し、呼び出しチェーンから Docker Engine の存在が消え、次のようなチェーンになりました：

```
K8s Master → kublet → KubeGenericRuntimeManager → containerd → runC
```

このように、抽象化の過程では、中間層を追加することで互換性を持たせることができます。たとえば、k3s は ETCD をストレージとして強く依存しませんが、watch などの機能が必要な場合は、該当するストレージにラッピングを追加する必要があります。総合的に性能や複雑度など様々な要素を考慮して決定します。

もちろん、このプロセスで仕様を定め、オープンソースとして他の開発者に実装を手伝ってもらい、自分の環境に合わせて適用することもできます。

## プログラミングパラダイムが教えてくれる「してはいけないこと」

構造化プログラミングは「goto」を使わず、「if else」を使うように教えてくれます。これによって後のコードをより小さなサブモジュールへ分割しやすくなります。

構造化プログラミングはプログラムの制御権の直接移譲を制限・規範化し、goto のような方法で制御権を移譲することを禁止しています。

オブジェクト指向プログラミングは、関数ポインタの濫用を制限します。ある構造を再利用したい場合はオブジェクトとして抽象化し、シングルトンパターンを使うことができます。しかし、二つのエンティティがお互いに影響し合いたくない場合は、それぞれ独立したエンティティを構築する必要があります。たとえ属性が同じでも、値は全く異なり、現実世界の二つの異なる個体を表しています。

このようにすることで、エンティティ間の属性の変化を隔離でき、現実のシナリオの実現にも役立ちます。例えば、どちらも「学生」であっても、学生 A と学生 B は独立した人間です。データ集計をする際も個別に処理できます。

関数型プログラミングは、値の代入を制限し、変数をインプレースで変更することを避けます。これによりエラーが起こりにくくなります。

```go
package main

import (
  "fmt"
)

// Map関数は整数スライスと関数を受け取り、
// 元のスライスの各要素に関数を適用した結果の新しい整数スライスを返します。
func MapInts(slice []int, f func(int) int) []int {
  result := make([]int, len(slice))
  for i, v := range slice {
    result[i] = f(v)
  }
  return result
}

func main() {
  // 元の整数スライス
  nums := []int{1, 2, 3, 4, 5}

  // 関数型プログラミングで各要素を2乗
  squared := MapInts(nums, func(x int) int {
    return x * x
  })

  // 結果を表示
  fmt.Println("Original:", nums)
  fmt.Println("Squared:", squared)
}
```

各プログラミングパラダイムの目的は制限を設けることです。これらのパラダイムは「何ができるか」ではなく、「何をしてはいけないか」を主に教えてくれます。

禁止されているパラダイムは絶対にやってはいけません。一方で禁止されていないことについては、ソフトウェア開発の過程でできるだけ組み合わせて活用し、プロジェクトをアジャイルに進めましょう。

## フレームワークへの強い依存は避ける

例えば、Golang の Gin に関連するデータ構造（gin.Context など）は、API レイヤーにだけ留め、アプリケーションレイヤーには持ち込まない方が良いです。

フレームワークへの依存は、プロキシクラスを作ることで実現するのが良いでしょう。

フレームワークの実装は、より詳細な部分にあたり、いわゆるファームウェア的なコードです。フレームワークが廃れた場合、もしコードがフレームワークに直接依存していると、依存関係の修正がとても面倒になります。利用している各業務側がすべて修正する必要が出てきます。

いわゆる「サービス」というもの自体は、関数呼び出しに比べて多少コストが高いだけで、アプリケーションの動作を分割する一つの形式に過ぎず、システムアーキテクチャとは直接関係ありません。

例を挙げると、もしメソッドを抽象化し、呼び出し先がカーネルコードであれ RPC であれ、それは実装の詳細であり、実装やデバッグの難易度に影響するだけです。アーキテクチャ設計時にはこうした詳細を切り離すべきです。

## 単一責任原則

モジュールやエンティティは一つのことだけを行うべきで、これが単一責任です。

エンティティ設計やサービス設計のいずれにおいても、Kubernetes はこの原則を比較的よく守っています。

たとえば kube-scheduler は Pod ノードのスケジューリングだけを担当します。この責任が定義されると、コーディングの際にさらに細かく分解できます。例えば、まずすべてのノードに予選スコアを付け、不適切なノードをフィルタリングし、最終的に Pod をある Node にバインドし、その Node 上の全ノードの状態は kubelet が管理します。この時、CNI と CRI はそれぞれコンテナのネットワークとランタイムのサンドボックス環境を担当し、Kubelet によって呼び出されます。

各コンポーネントがそれぞれ特定の役割を持っているのが分かります。一つの作業が複雑な場合は、他のコンポーネントを組み合わせてその作業を完成させます。

## 要件を示し、実装細部を示さない

リソースの状態を変更する際、k8s には期待する状態（desired state）を伝えるべきであり、k8s に「どうすべきか（how）」を指示するべきではありません。これが kubelet の rolling-update が廃止された理由でもあります。期待状態を伝えると、kubelet がそれに従って自身のアクションを取ることができ、外部から過度に介入する必要がなくなります。

この時、cAdvisor は k8s がデプロイしたコンテナを監視します。まず監視できるメトリックを確認し、自動スケーリング時にはそれらの指標を判断材料にします。

このように、異なるタスク用のコンポーネントを設計する際の原則は、達成したい要件を明確にし、情報の伝達は入力と出力にだけ注目し、内部の実装方法はカプセル化して外部に公開しないことです。外部からはできるだけシンプルに使えるようにしましょう。

## オープン・クローズド原則

拡張に対してオープン、修正に対してクローズドとは、ソースコードを変更せずにエンティティの振る舞いを変更できることを意味します。

Golang の構文は、主にコンポジションによって既存のエンティティを拡張し、インターフェースによる暗黙の型変換によって利用者が詳細を知らなくてもよくなります。

```go
type Kubelet struct{}

func (kl *Kubelet) HandlePodAdditions(pods []*Pod) {
  for _, pod := range pods {
    fmt.Printf("create pods : %s\n", pod.Status)
  }
}

func (kl *Kubelet) Run(updates <-chan Pod) {
  fmt.Println(" run kubelet")
  go kl.syncLoop(updates, kl)
}

func (kl *Kubelet) syncLoop(updates <-chan Pod, handler SyncHandler) {
  for {
    select {
    case pod := <-updates:
      handler.HandlePodAdditions([]*Pod{&pod})
    }
  }
}

type SyncHandler interface {
  HandlePodAdditions(pods []*Pod)
}
```

このように kubelet の機能を拡張する時、既存のロジックを修正する必要がありません。SyncHandler は型として扱われ、利用時は SyncHandler だけを保持すればよく、変更が必要な場合は Kubelet にメソッドを追加し、インターフェースで利用者に抽象化して渡せます。これにより、機能は Kubelet に拡張されても元のコードに影響を与えません。

## 最も再利用性が高いのはビジネスロジック

ビジネスロジックは、システムの中で最も独立していて、再利用性が最も高いコードであるべきです。

K8s のコアは、コンテナのオーケストレーションと状態管理にあります。どのようなストレージを使ってコンテナの状態を保存するか（ETCD かリレーショナルデータベースか）は、本質的には重要ではなく、この部分は柔軟に置き換えることができます。ネットワーク設定用の CNI も、実際のシナリオに応じて選択できます。しかし、コンテナのオーケストレーションと管理については、最初に決定した後は基本的に安定して変わらない部分です。

## まずは動かし、次に良くし、最後に速くする

- 「まずコードを動くようにする」——コードが動作しなければ価値を生み出しません。そのため、K8s の初期バージョンには多くの強い依存関係（flannel ネットワークプラグインや docker ランタイムへの強依存）が存在しました。
- 「その後で、それをより良くしようとする」——コードのリファクタリングを通じて、自分や他人がより理解しやすく、要件に応じて継続的に修正できるようにします。K8s は後期にこれらの強依存を取り除くため、CNI や CRI などの仕様を策定し、さまざまなベンダーが自社の物理環境に合わせて開発できるようにしました。これが「より良くしようとする」取り組みです。
- 「最後に、より速く動作させることを目指す」——パフォーマンス向上という“要件”に基づいてコードをリファクタリングします。これはソフトウェアが成熟する段階であり、最初に機能実装を行う際には細かい処理の問題や、モニタリングシステム自体が十分でなく問題の発見が難しいこともあります。しかし、機能が十分に揃ったら、部分的な最適化やアルゴリズムの置き換えにより、全体のパフォーマンスを向上させることができます。

---

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

[![Leapcell](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3967563/4e900d27-d080-415f-b676-689a1c1d3e61.png)](https://leapcell.io/?lc_t=q_gok8sarchdesign)

[Leapcell](https://leapcell.io/?lc_t=q_gok8sarchdesign)は、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/1bc8707d-d293-45b6-874f-5fb247ad4605.png)](https://leapcell.io/?lc_t=q_gok8sarchdesign)

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

---

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