K8sに学ぶGoエンジニアリング実践法

[![表紙](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3967563/7c89b392-ffe5-4171-94e3-1d51ac44f565.png)](https://leapcell.io/?lc_t=q_gok8seng)

## map の読書と書き込み

Kubernetes では、多くの変更が channel に書き込んだ後で実行されるのを見ることができます。これにより、単一ゴルーチンでの並行性の問題を回避し、プロデューサーとコンシューマーを分離することができます。

しかし、単にロックを使って map を修正するだけの場合、このときは channel のパフォーマンスはロックよりもはるかに劣ります。以下のコードでパフォーマンステストを行います。

`writeToMapWithMutex` はロックによって map を操作し、`writeToMapWithChannel` は channel に書き込んで別のゴルーチンで消費します。

```go
package map_modify

import (
  "sync"
)

const mapSize = 1000
const numIterations = 100000

func writeToMapWithMutex() {
  m := make(map[int]int)
  var mutex sync.Mutex

  for i := 0; i < numIterations; i++ {
    mutex.Lock()
    m[i%mapSize] = i
    mutex.Unlock()
  }
}

func writeToMapWithChannel() {
  m := make(map[int]int)
  ch := make(chan struct {
    key   int
    value int
  }, 256)

  var wg sync.WaitGroup
  go func() {
    wg.Add(1)
    for {
      entry, ok := <-ch
      if !ok {
        wg.Done()
        return
      }
      m[entry.key] = entry.value
    }
  }()

  for i := 0; i < numIterations; i++ {
    ch <- struct {
      key   int
      value int
    }{i % mapSize, i}
  }
  close(ch)
  wg.Wait()
}
```

ベンチマークでテスト

```shell
go test -bench .

goos: windows
goarch: amd64
pkg: golib/examples/map_modify
cpu: Intel(R) Core(TM) i7-9700 CPU @ 3.00GHz
BenchmarkMutex-8             532           2166059 ns/op
BenchmarkChannel-8           186           6409804 ns/op
```

ご覧の通り、ロックによる map の変更の方が効率が高いので、修正が複雑でない場合は、直接 sync.Mutex を使って並行修正問題を回避するのが優先です。

## 常に並行性を設計する

K8s では多くの channel を使ってシグナルを伝播し、自身のロジック処理が上下のコンポーネントの処理待ちでブロックされないようになっています。これによってタスク実行効率が向上し、プログラムがエラーを起こした際にも最小限の範囲でリトライが可能となり、冪等性設計も小さなモジュールに分解できます。

Pod の削除・追加・更新イベントの処理も並行で実行できます。一つの処理が終わるまで他を待つ必要はありません。そのため、Pod の追加時には複数の listener を登録して分配でき、channel に書き込んだ時点で実行成功とみなし、後続の信頼性は実行者側で保証します。これにより現在のイベントの並行実行がブロックされることもありません。

```go
type listener struct {
  eventObjs chan eventObj
}

// watch
//
//  @Description: 監視すべき内容をリッスン
func (l *listener) watch() chan eventObj {
  return l.eventObjs
}

// イベントオブジェクト。伝えたい内容を自由に定義可能
type eventObj struct{}

var (
  listeners = make([]*listener, 0)
)

func distribute(obj eventObj) {
  for _, l := range listeners {
    // ここで直接イベントオブジェクトを分配
    l.eventObjs <- obj
  }
}
```

DeltaFIFO の設計における Delete 同一動作の重複排除：

```go
func dedupDeltas(deltas Deltas) Deltas {
  n := len(deltas)
  if n < 2 {
    return deltas
  }
  a := &deltas[n-1]
  b := &deltas[n-2]
  if out := isDup(a, b); out != nil {
    deltas[n-2] = *out
    return deltas[:n-1]
  }
  return deltas
}

func isDup(a, b *Delta) *Delta {
  // どちらも削除操作なら一つにまとめる
  if out := isDeletionDup(a, b); out != nil {
    return out
  }
  return nil
}
```

ここで、イベントが個別のキューで管理されているため、キューに重複処理のロジックを追加できます。

コンポーネントがパッケージ内にカプセル化されているため、外部は内部の複雑性を見ることなく、後続処理のみを続ければよく、削除イベントが一つ減っても全体のロジックには影響しません。

## コンポーネント間の直交設計

直交設計とは何でしょうか？それは、コンポーネント同士の機能が独立しており、自由に組み合わせられ、互いに依存しないことを指します。たとえば kube-scheduler は Pod に具体的なノードを割り当てるだけであり、割り当てた後は直接 kubelet に渡すのではなく、api-server 経由で etcd に格納します。これにより、kube-scheduler は api-server からタスクを受け取ることだけに依存します。

kubelet もまた api-server からタスクを監視するので、kube-scheduler からのタスクだけでなく、api-server の Pod 削除リクエストにも対応できます。それぞれが独立してできることの積は、最終的に実現できる全体の機能となります。

## タイマーの実装

Crontab で定期的にタスクをトリガーできます。まず、タスク後の処理ロジック用インターフェースを用意し、curl イメージで定期的にタスクを起動します。

```yaml
apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: task
spec:
  schedule: '0 10 * * *'
  jobTemplate:
    spec:
      template:
        spec:
          containers:
            - name: task-curl
              image: curlimages/curl
              resources:
                limits:
                  cpu: '200m'
                  memory: '512Mi'
                requests:
                  cpu: '100m'
                  memory: '256Mi'
              args:
                - /bin/sh
                - -c
                - |
                  echo "Starting create task of CronJob"
                  resp=$(curl -H "Content-Type: application/json" -v -i -d '{"params": 1000}' <http://service-name>:port/api/test)
                  echo "$resp"
                  exit 0
          restartPolicy: Never
  successfulJobsHistoryLimit: 2
  failedJobsHistoryLimit: 3
```

## 抽象化ファームウェアコード

Kubernetes には CNI もこの思想で設計されています。k8s はネットワークプラグイン用に一連のルールを定めています。CNI の目的はネットワーク構成をコンテナプラットフォームから分離することです。異なるプラットフォームでは異なるネットワークプラグインを使うだけで、他のコンテナ関連の内容は再利用できます。コンテナを作った後のネットワーク部分は CNI プラグインに任せ、CNI プラグインが規定の設定を受け取るだけです。

業務実装でも CNI のようにプラグイン可能なコンポーネントを設計できるでしょうか？

答えはもちろん「できます」。業務開発でよく使われるのがデータベースですが、データベースは業務ロジックが間接的に使うツールであるべきです。業務ロジックはデータベースのテーブル構造やクエリ言語、内部実装の詳細を知る必要はありません。\*\*業務ロジックが知る必要があるのは、データのクエリや保存に使う関数群だけです。\*\*これにより、データベースはインターフェースの背後に隠すことができます。

異なるバックエンドデータベースが必要な場合、コードレベルで初期化処理を切り替えるだけです。gorm も大部分のドライバを抽象化しているため、実際の初期化時に異なる DSN を渡すだけで、異なるドライバが有効になり、必要な処理文を翻訳してくれます。

良いアーキテクチャ設計はユースケースを中心に展開すべきです。そうすればフレームワークやツール、利用環境に依存せず、ユースケースを完全に記述できます。

これは、住宅建築の設計目標がまず住宅の利用ニーズを満たすことであり、必ずレンガを使うことではないのと同じです。アーキテクトはユースケースのニーズを満たしつつ、可能な限り自由に建材を選べるように配慮すべきです。

gorm とデータベースの間の抽象化のおかげで、ユーザー登録やログインの実装で、バックエンドが mysql か postgres かを気にせずに済みます。まずユーザー登録でユーザー情報を保存し、ログイン時に対応パスワードを比較する仕様だけ決め、信頼性や性能要件に応じて実装段階で適切なコンポーネントを選ぶだけです。

## 過度な設計をしない

過剰なエンジニアリングデザインは、不足よりも時に悪影響を及ぼします。

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

Kubernetes の発展とともに、Flannel だけでは一部のケースに対応しきれなくなり、2015 年頃にはコミュニティから Calico や Weave が登場し、ネットワーク問題はほぼ解決されました。Kubernetes 自身がこれ以上ネットワークに注力する必要がなくなったため、CNI が導入され、ネットワークプラグインの標準化が図られました。

Kubernetes の設計も最初から完璧だったわけではなく、問題が現れるたびに新しい設計を導入し、様々な環境の変化に適応してきたことが分かります。

## scheduler framework

kube-scheduler では framework のフックポイントによって、後からプラグインを追加できるようになっています。たとえばノードのスコアを評価するプラグインを追加したい場合は、`ScorePlugin` インターフェースを実装し、`Registry` でプラグインを framework の `scorePlugins` 配列に登録するだけです。最終的には `Status` で scheduler の結果（エラー内容、コード、エラー原因となったプラグイン名を含む）をラップします。

もし framework の挿入ポイントが設定されていないと、ロジックが分散し、新たな機能を追加するとき統一されたフックポイントがないため、コードのあちこちにロジックを追加する羽目になります。

framework の抽象化を加えることで、どのフェーズでロジックを追加したいかだけを理解し、そのロジックを実装して登録するだけで済みます。これにより、各コンポーネントのテストも容易になり、記述の規範化も進みます。ソースコードを読む際も自分が変更・理解したいロジックだけを見ればよくなります。

簡略化した例は以下の通りです：

```go
type Framework struct {
  sync.Mutex
  scorePlugins []ScorePlugin
}

func (f *Framework) RegisterScorePlugin(plugin ScorePlugin) {
  f.Lock()
  defer f.Unlock()
  f.scorePlugins = append(f.scorePlugins, plugin)
}

func (f *Framework) runScorePlugins(node string, pod string) int {
  var score int
  for _, plugin := range f.scorePlugins {
    score += plugin.Score(node, pod) // プラグインごとに重みが違う場合はここで掛け算
  }
  return score
}
```

このようなインターフェースの集約は、同種のコンポーネントへの共通処理の追加にも役立ちます。例えばスコアリングプラグインは複数ノードで同時にスコア計算でき、一つずつ待つ必要がありません。

```go
type Parallelizer struct {
  Concurrency int
  ch          chan struct{}
}

func NewParallelizer(concurrency int) *Parallelizer {
  return &Parallelizer{
    Concurrency: concurrency,
    ch:          make(chan struct{}, concurrency),
  }
}

type DoWorkerPieceFunc func(piece int)

func (p *Parallelizer) Until(pices int, f DoWorkerPieceFunc) {
  wg := sync.WaitGroup{}
  for i := 0; i < pices; i++ {
    p.ch <- struct{}{}
    wg.Add(1)
    go func(i int) {
      defer func() {
        <-p.ch
        wg.Done()
      }()
      f(i)
    }(i)
  }
  wg.Wait()
}
```

クロージャで計算コンポーネント情報を渡し、`Parallelizer` によって並行実行します。

```go
func (f *Framework) RunScorePlugins(nodes []string, pod *Pod) map[string]int {
  scores := make(map[string]int)
  p := concurrency.NewParallelizer(16)
  p.Until(len(nodes), func(i int) {
    scores[nodes[i]] = f.runScorePlugins(nodes[i], pod.Name)
  })
  // ノードのバインディングロジックは省略
  return scores
}
```

このプログラミングパターンは業務にも応用できます。例えば推薦結果のリコール後、フィルタやソートなどの戦略を適用する場面です。

戦略オーケストレーションに更新がある場合はホットリロードが必要で、フィルタ内部のロジックデータ（ブラックリストや購入履歴、商品の在庫状況など）も変化する可能性があります。その際、既に起動済みのタスクは元の戦略でフィルタリングし、新しいタスクには新しいルールを適用します。

```go
type Item struct{}

type Filter interface {
  DoFilter(items []Item) []Item
}

// ConstructorFilters
//
//  @Description: 毎回新しいFilterを構築。キャッシュが変われば更新。新タスクは新しいフィルタチェーンを取得
//  @return []Filter
func ConstructorFilters() []Filter {
  // filters 戦略は設定ファイルから直接読み取って初期化可能
  return []Filter{
    &BlackFilter{}, // 内部ロジックの変化はコンストラクタで、毎回異なるロジックを構築可能
    &AlreadyBuyFilter{},
  }
}

func RunFilters(items []Item, fs []Filter) []Item {
  for _, f := range fs {
    items = f.DoFilter(items)
  }
  return items
}
```

## サービス分割＝アーキテクチャ設計ではない

サービスを分割しても、サービス間の結合はコード上の結合からデータ上の結合に変わるだけです。たとえば、下流サービスでフィールドを修正する必要があれば、上流の連鎖も同様にそのフィールドを処理しなければなりません。局所的な分離はできますが、もしサービスを分割しなくても、コード上で階層を分けて局所的な分離を実現できますし、関数の入力や出力だけでもサービス分割と同じ効果が得られます。

サービス分割は、システムプログラムを分割する一つの手段にすぎません。サービスの境界がシステムの境界とは限らず、サービスの境界はむしろコンポーネントの境界です。一つのサービスの中にも複数のコンポーネントが存在し得ます。

コンポーネントとは、デプロイ可能な最小単位です。

たとえば k8s の api-server。

または、推薦システムの中で「推薦タスク」と「推薦リスト」が同じコンポーネントに含まれる場合、推薦タスクには様々なタイプ（集団への商品推薦、特定ユーザーへの商品推薦、広告配信など）がありますが、これらはすべて一つのコンポーネントに抽象化されています。下流がこれらのデータを利用する際、内部でどのようなルールで生成されたかを意識する必要はなく、「どのユーザーにどの商品を推薦したか」だけを見れば十分です。これが上下流の境界の抽象です。推薦タスクの内部ロジックが変わっても、最終的に下流が受け取るデータには影響しません。下流が得るのは常に (user, item) のペアです。推薦サービスのロジックが一つのコンポーネントとなり、単独でデプロイすることで上下流が利用できるようになります。

## Main の起動

cobra を使うことで、構造化されたコマンドを構築できます。

```shell
kubelet --help
```

このコマンドによって、コマンドラインツールのオプションパラメータが確認できます。

もし自分が Web サーバアプリケーションを作る場合、パラメータを渡して起動時にリッスンするポートや設定ファイルの値を変更できます。

コマンドラインツールとしてプログラムを書く場合も、パラメータを柔軟に公開し、ユーザー自身にコマンドの挙動を決定してもらうことができます。

---

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

[![Leapcell](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3967563/bde16852-aa25-46bb-9fe0-2539d21f8b45.png)](https://leapcell.io/?lc_t=q_gok8seng)

[Leapcell](https://leapcell.io/?lc_t=q_gok8seng)は、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/5749640c-3424-4747-a38b-ddb6b68f9e96.png)](https://leapcell.io/?lc_t=q_gok8seng)

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

---

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