Kubernetesリクエスト同時制御とデータ整合性(ResourceVersion、Update、Patchプロファイルを含む)


大規模な分散システムでは、同時書き込みのシーンが大量に存在するに違いありません.このようなシナリオでどのようにより良い同時制御を行うか、すなわち、複数のタスクが同時にデータにアクセスする際にデータの一貫性を保証することは、分散システムが解決しなければならない問題となっている.
悲観的同時制御と楽観的同時制御は同時制御に採用される主な技術手段であり、異なる業務シーンに対して、異なる制御方法を選択すべきである.

悲観ロック


悲観同時制御(別名「悲観ロック」、Pessimistic Concurrency Control、略称「PCC」)は同時制御の方法である.トランザクションをブロックして、他のユーザーに影響を与える方法でデータを変更できます.トランザクションが実行するオペレーションがローのデータを読み取りロックを適用した場合、他のトランザクションがロックと競合するオペレーションを実行できるのは、トランザクションがロックを解放するときだけです.
悲観的なロックのシーンでは、ユーザAとBが同じファイルを修正すると仮定し、Aがファイルをロックして修正する過程で、Bはこのファイルを修正することができず、Aの修正が完了し、ロックが解除された後、Bはロックを取得し、ファイルを修正することができる.このことから、悲観的なロックは同時の制御に対して悲観的な態度を持っており、それはいかなる修正を行う前に、まずそれにロックをかけ、修正過程全体で衝突しないことを確保し、それによって効果的にデータの一致性を保証することができる.しかし、このようなメカニズムは、システムの同時性を同時に低下させ、特に2つの同時に修正されたオブジェクト自体が衝突しない場合を低減する.競合ロック時にデッドロックが発生する可能性もあるため、現在多くのシステム、例えばKubernetesは楽観的な同時制御方法を採用している.

楽観ロック


楽観同時制御(「楽観ロック」、Optimistic Concurrency Control、略称「OCC」)は、同時制御の方法である.マルチユーザ同時トランザクションは、処理時に互いに影響を及ぼさず、各トランザクションはロックを要求せずにそれぞれのデータを処理できると仮定します.データ更新をコミットする前に、各トランザクションは、トランザクションがデータを読み出した後に、他のトランザクションがデータを変更したかどうかを確認します.他のトランザクションが更新された場合、コミット中のトランザクションはロールバックされます.
悲観的なロックによるロックの早期制御に対して、楽観的なロックは、リクエスト間の競合が発生する確率が比較的小さいと信じ、読み取りおよび変更の過程でロックされず、最終的に更新がコミットされたときにのみ競合が検出されるため、高同時量のシステムでは絶対的な優位性を占めています.同様に、ユーザAとBが同じファイルを変更すると仮定すると、AとBはまずファイルをローカルに取得し、変更する.Aが修正され、データがコミットされた場合、Bがコミットされると、サーバ側はBファイルが変更されたことを通知し、競合エラーを返します.この場合、競合はBによって解決され、ファイルを再取得し、もう一度修正して提出することができます.
楽観的なロックは、通常、リソースバージョンフィールドを追加することによって、リクエストが競合しているかどうかを判断します.初期化時にバージョン値を指定し、データを読み出すたびにバージョン番号を一緒に読み出し、データを更新するたびにバージョン番号を更新します.サーバ側がデータを受信すると、データのバージョン番号をサーバ側と比較し、一致しない場合は、データが変更され、競合エラーが返されることを示します.

Kubernetesでの同時制御


Kubernetesクラスタでは,外部ユーザおよび内部コンポーネントの頻繁なデータ更新操作により,システムのデータ同時読み書き量が非常に大きい.悲観的並列制御法を採用すると,クラスタ性能が著しく損なわれると仮定し,Kubernetesは楽観的並列制御法を採用した.Kubernetesは、リソースバージョンフィールドを定義することによって楽観的な同時制御を実現し、リソースバージョン(ResourceVersion)フィールドはKubernetesオブジェクトのメタデータ(Metadata)に含まれる.この文字列フォーマットのフィールドは、etcdのmodifiedindexから値を取得するオブジェクトの内部バージョン番号を識別し、オブジェクトが変更されると、フィールドが変更されます.このフィールドは、クライアントでの変更を推奨しないサービス側によって維持されていることに注意してください.
type ObjectMeta struct {
    ......

    // An opaque value that represents the internal version of this object that can
    // be used by clients to determine when objects have changed. May be used for optimistic
    // concurrency, change detection, and the watch operation on a resource or set of resources.
    // Clients must treat these values as opaque and passed unmodified back to the server.
    // They may only be valid for a particular resource or set of resources.
    //
    // Populated by the system.
    // Read-only.
    // Value must be treated as opaque by clients and .
    // More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#concurrency-control-and-consistency
    // +optional
    ResourceVersion string
    
    ......
}

Kube-Apiserverは、このフィールドでオブジェクトが変更されたかどうかを判断できます.ResourceVersionを含む更新要求がApiserverに到達すると、サーバ側は要求データとサーバ内のデータのリソースバージョン番号を比較し、一致しない場合は、今回の更新コミット時にサービス側オブジェクトが変更されたことを示し、Apiserverは競合エラー(409)を返し、クライアントはサービス側データを再取得し、再修正してからサーバ側に再送信する必要がある.上記並列制御方法は、以下のdata raceを防止することができる.
Client #1: GET Foo
Client #2: GET Foo

Client #1: Set Foo.Bar = "one" 
Client #1: PUT Foo 

Client #2: Set Foo.Baz = "two"
Client #2: PUT Foo

同時制御が採用されていない場合、上記のような要求シーケンスが発生し、2つのクライアントが同時にサービス側から同じオブジェクトFoo(Bar、Bazの2つのフィールドを含む)を取得し、Client 1はまずBarフィールドをoneにし、その後、Client 2はBazフィールドに値を付与する更新要求がサービス側に与えられると、Client 1のBarに対する修正が上書きされる.逆に、オブジェクトにリソースバージョンフィールドを追加すると、同じリクエストシーケンスが次のようになります.
Client #1: GET Foo  // Foo.ResourceVersion=1
Client #2: GET Foo  // Foo.ResourceVersion=1

Client #1: Set Foo.Bar = "one" 
Client #1: PUT Foo  // Foo.ResourceVersion=2

Client #2: Set Foo.Baz = "two"
Client #2: PUT Foo  // 409 

Client#1がオブジェクトを更新するとリソースバージョン番号が変更され、Client#2は更新コミット時に競合エラー(409)を返します.この場合、Client#2はローカルでデータを再取得し、更新後にサービス側にコミットする必要があります.
更新要求のオブジェクトにResourceVersion値が設定されていないと仮定すると、Kubernetesはハード書き換えポリシー(構成可能)に基づいてハード更新を行うかどうかを決定します.ハード書き換え可能に構成されている場合、データは直接更新されEtcdに格納され、逆にエラーが返され、ResourceVersionを指定する必要があることをユーザーに通知します.

KubernetesのUpdateとPatch


KubernetesはUpdateとPatchの2つのオブジェクトの更新方法を実現し,両者は異なる更新操作方式を提供するが,衝突判断メカニズムは同じである.

Update


Updateの場合、クライアント更新要求にはobjオブジェクト全体が含まれており、サーバ側はその要求のobjオブジェクトとサーバ側の最新objオブジェクトのResourceVersion値を比較します.等しい場合は、競合が発生していないことを示し、オブジェクト全体が正常に更新されます.逆に等しくなければ409競合エラーを返し、Kube-Apiserverで競合判定されたコードフラグメントは以下のようになる.
 e.Storage.GuaranteedUpdate(ctx, key...) (runtime.Object, *uint64, error) {
		// If AllowUnconditionalUpdate() is true and the object specified by
		// the user does not have a resource version, then we populate it with
		// the latest version. Else, we check that the version specified by
		// the user matches the version of latest storage object.
		resourceVersion, err := e.Storage.Versioner().ObjectResourceVersion(obj)
		if err != nil {
			return nil, nil, err
		}
                version, err := e.Storage.Versioner().ObjectResourceVersion(existing)
		doUnconditionalUpdate := resourceVersion == 0 && e.UpdateStrategy.AllowUnconditionalUpdate()
                
		if doUnconditionalUpdate {
			// Update the object's resource version to match the latest
			// storage object's resource version.
			err = e.Storage.Versioner().UpdateObject(obj, res.ResourceVersion)
			if err != nil {
				return nil, nil, err
			}
		} else {
			// Check if the object's resource version matches the latest
			// resource version.
			......
			if resourceVersion != version {
				return nil, nil, kubeerr.NewConflict(qualifiedResource, name, fmt.Errorf(OptimisticLockErrorMsg))
			}
		}
       ......
	return out, creating, nil
}

基本手順は次のとおりです.
1.現在の更新要求におけるobjオブジェクトのResourceVersion値と、サーバ側の最新objオブジェクトのResourceVersion値を取得する
2.現在の更新要求におけるobjオブジェクトのResourceVersion値が0に等しい場合、すなわちクライアントが値を設定していない場合、ハード上書きポリシーとして構成されている場合、objオブジェクトが直接更新されるように、AllowUnconditionalUpdateをハード上書きするかどうかを判断します.
3.現在の更新要求においてobjオブジェクトのResourceVersion値が0に等しくない場合、2つのResourceVersion値が一致するか否かを判断し、一致しない場合は競合エラー(OptimisticLockeErrorMsg)を返す.

Patch


Updateリクエストがobjオブジェクト全体を含むよりも、Patchリクエストは更新が必要なフィールドのみを含むより微細なオブジェクト更新操作を実現します.たとえばpodのcontainerのミラーを更新するには、次のコマンドを使用します.
kubectl patch pod my-pod -p '{"spec":{"containers":[{"name":"my-container","image":"new-image"}]}}'

サーバ側は、上記のpatch情報のみを受信し、次のコードでEtcdに更新する.
func (p *patcher) patchResource(ctx context.Context) (runtime.Object, error) {
	p.namespace = request.NamespaceValue(ctx)
	switch p.patchType {
	case types.JSONPatchType, types.MergePatchType:
		p.mechanism = &jsonPatcher{patcher: p}
	case types.StrategicMergePatchType:
		schemaReferenceObj, err := p.unsafeConvertor.ConvertToVersion(p.restPatcher.New(), p.kind.GroupVersion())
		if err != nil {
			return nil, err
		}
		p.mechanism = &smpPatcher{patcher: p, schemaReferenceObj: schemaReferenceObj}
	default:
		return nil, fmt.Errorf("%v: unimplemented patch type", p.patchType)
	}
	p.updatedObjectInfo = rest.DefaultUpdatedObjectInfo(nil, p.applyPatch, p.applyAdmission)
	return finishRequest(p.timeout, func() (runtime.Object, error) {
		updateObject, _, updateErr := p.restPatcher.Update(ctx, p.name, p.updatedObjectInfo, p.createValidation, p.updateValidation, false, p.options)
		return updateObject, updateErr
	})
}

プロセス:
1.まずpatchタイプを判断し、タイプに応じて対応するmechanismを選択する
2.DefaultUpdatedObjectInfoメソッドを使用して、applyPatch(Patchを適用するメソッド)をadmission chainのヘッダに追加します.
3.最終的には、上記のUpdateメソッドを呼び出して更新操作を実行します.
ステップ2でapplyPatchメソッドをadmission chainのヘッダに掛け、admission動作と同様に、applyPatchメソッドはpatchを最新取得したサーバ側objに適用し、更新されたobjを生成し、そのobjに対してadmission chainのAdmitとValidateを実行し続ける.最終的に呼び出されるのはupdateメソッドであるため、競合検出のメカニズムは上記のUpdateメソッドと完全に一致する.
Updateに比べて、Patchの主な利点は、クライアントがobjオブジェクト情報を全量提供する必要がないことである.クライアントはpatchで変更するフィールド情報をコミットするだけで、サーバ側はこのpatchデータを最新に取得したobjに適用します.Client側が取得、修正して全量objをコミットする手順を省略し、データが修正されるリスクを低減し、衝突確率を大幅に低減した.Patchメソッドは伝送効率と競合確率において絶対的な優位性を占めているため,現在Kubernetesではほとんどの更新操作にPatchメソッドが採用されているが,コードを記述する際にもPatchメソッドの使用に注意すべきである.
異なるPatchタイプの詳細の違いを参照してください.
https://support.huaweicloud.com/api-cci/cci_02_0070.html

添付:


ResourceVersionフィールドは、Kubernetesにおいて上述した同時制御機構に加えて、Kubernetesのlist-watch機構においても用いられる.Client側のlist-watchは、listがすべてのオブジェクトを取り戻し、その後のオブジェクトをインクリメンタルにwatchする2つのステップに分けられます.Client側はlistがすべてのオブジェクトを取り戻した後、最新のオブジェクトのResourceVersionを次のwatch操作の起点パラメータ、すなわちKube-Apiserverとして受信したResourceVersionを始点として後続データを返し、list-watchにおけるデータの連続性と完全性を保証する.