KubernetesからGo Engineering実践を学ぶ
Olivia Novak
Dev Intern · Leapcell

Map Read and Write
Kubernetesでは、多くの変更が実行前にチャネルに書き込むことによって実行されるのをよく見かけます。このアプローチにより、シングルスレッドルーチンは同時実行の問題を回避し、また、生成と消費を分離します。
ただし、単にロックしてマップを修正する場合、チャネルを使用するパフォーマンスは、直接ロックするほど良くありません。パフォーマンスをテストするための以下のコードを見てみましょう。
writeToMapWithMutex
はロックによってマップを操作し、一方writeToMapWithChannel
はチャネルに書き込み、それは別のゴルーチンによって消費されます。
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() }
ベンチマークテスト:
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
マップを直接ロックして修正する方が効率的であることがわかります。したがって、修正が複雑でない場合は、同時修正の問題を避けるために、sync.Mutex
を直接使用することをお勧めします。
Always Design for Concurrency
K8sは、シグナルを渡すためにチャネルを広範囲に使用しているため、独自のロジック処理が、上流または下流のコンポーネントの未完了の作業によってブロックされることはありません。これにより、タスクの実行効率が向上するだけでなく、エラーが発生した場合の最小限のリトライが可能になり、冪等性を小さなモジュールに分割できます。
Podの削除、追加、更新などのイベントの処理はすべて、同時実行できます。ある処理が終了するのを待ってから次の処理を行う必要はありません。したがって、Podが追加されたら、配布のために複数のリスナーを登録できます。イベントをチャネルに書き込む限り、それは正常に実行されたとみなされ、後続のアクションの信頼性はエグゼキューターによって保証されます。このようにして、現在のイベントは同時実行からブロックされません。
type listener struct { eventObjs chan eventObj } // watch // // @Description: Listen to the content that needs to be handled func (l *listener) watch() chan eventObj { return l.eventObjs } // Event object, you can define the content to be passed as you like type eventObj struct{} var ( listeners = make([]*listener, 0) ) func distribute(obj eventObj) { for _, l := range listeners { // Directly distribute the event object here l.eventObjs <- obj } }
DeltaFIFOの削除アクションの重複排除
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 both are delete operations, merge one away if out := isDeletionDup(a, b); out != nil { return out } return nil }
ここで、イベントは個別のキューによって管理されているため、キューに個別に重複排除ロジックを追加できることがわかります。
コンポーネントはパッケージ内にカプセル化されているため、外部ユーザーは内部の複雑さを目にすることはありません。後続のイベントの処理を継続するだけで済みます。1つの削除イベントを削除しても、全体的なロジックに影響はありません。
Orthogonal Design Between Components
直交設計とは何ですか?それは、各コンポーネントが行うことが他のコンポーネントから独立しており、相互依存性なしに自由に構成できることを意味します。たとえば、kube-schedulerは、Podに特定のノードを割り当てることのみを担当し、割り当て後、結果を直接kubeletに渡して操作させることはしません。代わりに、api-serverを介してetcdに割り当てを保存します。このようにして、タスクを配信するためにapi-serverのみに依存します。
Kubeletはまた、api-serverによって配信されたタスクを直接リッスンします。したがって、kube-schedulerによって配信されたタスクを維持できるだけでなく、Podを削除するためのapi-serverからのリクエストも処理できます。したがって、彼らが独立してできることは、彼らが一緒に達成できることの総数を増やすために掛け合わされます。
Implementation of Timers
Crontabを使用して定期的にタスクをトリガーするには、最初にタスクがトリガーされた後のロジックを処理するためのインターフェイスを作成し、次にcurlイメージを使用してスケジュールに従ってタスクを開始できます。
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
Abstracting Firmware Code
Kubernetesはまた、CNI(Container Network Interface)の設計でこのアプローチに従っており、k8sはネットワークプラグインの一連のルールを確立しています。CNIの目的は、ネットワーク構成をコンテナプラットフォームから分離することであり、異なるプラットフォームでは異なるネットワークプラグインを使用するだけで済み、他のコンテナ化されたコンテンツは引き続き再利用できます。コンテナが作成されたことを知るだけでよく、残りのネットワークはCNIプラグインによって処理されます。仕様で合意された構成をCNIプラグインに提供するだけです。
ビジネス実装でCNIのようなプラグ可能なコンポーネントを設計できますか?
もちろん、できます。ビジネス開発で最も一般的に使用されるのはデータベースであり、ビジネスロジックによって間接的に使用されるツールである必要があります。ビジネスロジックは、データベースのテーブル構造、クエリ言語、またはその他の内部の詳細を知る必要はありません。**ビジネスロジックが知る必要があるのは、データのクエリと保存に使用できる関数があることだけです。**このようにして、データベースをインターフェイスの背後に隠すことができます。
別の基盤となるデータベースが必要な場合は、コードレベルでデータベースの初期化を切り替えるだけで済みます。Gormは、ほとんどのドライバーロジックを抽象化しているため、初期化中に異なるDSNを渡すだけで、異なるドライバーが有効になり、実行する必要のあるステートメントが変換されます。
優れたアーキテクチャは、ユースケースを中心に構造化されている必要があり、フレームワーク、ツール、またはランタイム環境に依存せずに、ユースケースを完全に記述できます。
これは、住宅の設計の主な目標が、レンガで家を建てることを主張するのではなく、居住のニーズを満たすことであるのと似ています。建築家は、ユーザーがニーズを満たしながら建築材料を自由に選択できるように、アーキテクチャに多大な労力を費やす必要があります。
gormと実際のデータベースの間に抽象化レイヤーがあるため、ユーザー登録とログインを実装するために、基盤となるデータベースがMySQLまたはPostgresであるかどうかを気にする必要はありません。ユーザー登録後、ユーザー情報が保存され、ログインのために、対応するパスワードをチェックする必要があると記述するだけです。次に、システムの信頼性とパフォーマンスの要件に基づいて、実装中に使用するコンポーネントを柔軟に選択できます。
Avoid Overengineering
過剰なエンジニアリングは、不十分なエンジニアリング設計よりも悪いことがよくあります。
Kubernetesの最初のバージョンは0.4でした。そのネットワーク部分では、当時の公式の実装は、GCEを使用してsaltスクリプトを実行してブリッジを作成し、他の環境では、推奨されるソリューションはFlannelとOVSでした。
Kubernetesが進化するにつれて、Flannelは場合によっては不適切になりました。2015年頃、CalicoとWeaveがコミュニティに登場し、基本的にネットワークの問題を解決したため、Kubernetesはこれに独自の努力を費やす必要がなくなりました。したがって、CNIが導入され、ネットワークプラグインが標準化されました。
わかるように、Kubernetesは最初から完全に設計されたわけではありません。代わりに、より多くの問題が発生するにつれて、変化する環境に適応するために、新しい設計が継続的に導入されました。
Scheduler Framework
kube-schedulerでは、フレームワークはマウントポイントを提供し、後でプラグインを追加できるようにします。たとえば、ノードスコアリングプラグインを追加する場合は、ScorePlugin
インターフェイスを実装し、Registryを介してフレームワークのscorePlugins
配列にプラグインを登録するだけです。最後に、スケジューラによって返される結果は、エラー、コード、およびエラーの原因となったプラグインの名前を含むStatus
でラップされます。
フレームワークの挿入ポイントが設定されていない場合、実行ロジックは比較的散らかります。ロジックを追加するとき、統一されたマウントポイントがないため、最終的にはどこにでもロジックを追加することになる可能性があります。
フレームワークの抽象化により、ロジックを追加するステージを知るだけで済みます。コードを記述した後、登録するだけです。これにより、個々のコンポーネントのテストが容易になり、各コンポーネントの開発が標準化され、ソースコードを読むときに、修正または理解したい部分のみをチェックする必要があります。
以下は、簡略化されたコード例です。
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) // Here, if plugins have different weights, you can multiply by a weight } return score }
この集中型アプローチにより、同様のコンポーネントに統一された処理ロジックを簡単に追加することもできます。たとえば、スコアリングプラグインは、各ノードが1つずつ終了するのを待たずに、複数のノードのスコアを同時に計算できます。
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にそれらを同時に実行させることができます。
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) }) // Node binding logic omitted return scores }
このプログラミングパラダイムは、ビジネスシナリオで非常によく適用できます。たとえば、レコメンデーションの結果でリコールした後、多くの場合、さまざまな戦略を通じてフィルタリングおよびソートする必要があります。
戦略オーケストレーションに更新がある場合は、ホットリロードが必要であり、ブラックリストの変更、購入したユーザーデータの変更、製品ステータスの変更など、フィルターの内部ロジックデータも変更される可能性があります。この時点で、実行中のタスクは引き続き古いフィルタリングロジックを使用する必要がありますが、新しいタスクは新しいルールを使用します。
type Item struct{} type Filter interface { DoFilter(items []Item) []Item } // ConstructorFilters // // @Description: A new Filter is constructed each time, and if the cache changes, it is updated. New tasks will use the new filter chain. // @return []Filter func ConstructorFilters() []Filter { // The filter strategies here can be read from the config file and then initialized return []Filter{ &BlackFilter{}, // If internal logic changes, it can be updated via the constructor &AlreadyBuyFilter{}, } } func RunFilters(items []Item, fs []Filter) []Item { for _, f := range fs { items = f.DoFilter(items) } return items }
Splitting Services Does Not Equal Architecture Design
サービスを分割すると、実際にはサービス間の結合がコードレベルの結合からデータレベルの結合に変わるだけです。たとえば、ダウンストリームサービスでフィールドを修正する必要がある場合、アップストリームパイプラインもそのフィールドを処理する必要があります。これはローカライズされた分離にすぎません。ただし、サービスを分割しない場合でも、コードをレイヤー化することで同様の分離を実現できます。関数の入出力を使用して、サービス分割と同様の効果を実現します。
サービスの分割は、システムプログラムを分割する1つの方法にすぎず、サービス境界はシステム境界ではありません。サービス境界は、コンポーネント境界についてです。サービスには、複数の種類のコンポーネントを含めることができます。
たとえば、k8s api-server。
または、レコメンデーションシステムでは、レコメンデーションタスクとレコメンデーションリストの両方が1つのコンポーネントに存在する可能性があります。レコメンデーションタスクには、多くの種類があります。製品をグループにプッシュする、製品のバッチを特定のユーザーにプッシュする、広告配信など。これらはさまざまな種類のレコメンデーションタスクですが、1つのコンポーネント内で抽象化されています。このデータを使用するダウンストリームユーザーは、内部でどのようなルールを使用して生成されたかを知りません。特定の製品がユーザーにレコメンデーションされたことを認識するだけです。これがアップストリームとダウンストリームの境界の抽象化です。レコメンデーションタスクコンポーネント内のロジックの変更は、ダウンストリームサービスが消費するデータには影響しません。常に(ユーザー、アイテム)のペアが表示されます。したがって、レコメンデーションサービスロジックはコンポーネントであり、独立してデプロイされた後、アップストリームとダウンストリームの両方で使用できます。
Main Function Startup
cobraを使用して、構造化されたコマンドを構築できます。
kubelet --help
このコマンドを使用すると、CLIツールのオプションのパラメーターを確認できます。
アプリケーションがWebサーバーの場合、どのポートをリッスンするか、どの構成ファイルを使用するかなど、パラメーターを渡すことで起動動作を変更できます。
プログラムがCLIツールの場合は、パラメーターをより柔軟に公開して、ユーザーがコマンドの動作を自分で決定できるようにすることができます。
We are Leapcell, your top choice for hosting Go projects.
Leapcell is the Next-Gen Serverless Platform for Web Hosting, Async Tasks, and Redis:
Multi-Language Support
- Develop with Node.js, Python, Go, or Rust.
Deploy unlimited projects for free
- pay only for usage — no requests, no charges.
Unbeatable Cost Efficiency
- Pay-as-you-go with no idle charges.
- Example: $25 supports 6.94M requests at a 60ms average response time.
Streamlined Developer Experience
- Intuitive UI for effortless setup.
- Fully automated CI/CD pipelines and GitOps integration.
- Real-time metrics and logging for actionable insights.
Effortless Scalability and High Performance
- Auto-scaling to handle high concurrency with ease.
- Zero operational overhead — just focus on building.
Explore more in the Documentation!
Follow us on X: @LeapcellHQ