apimachineryのQuantity型の扱い

::: note info
これは [ZOZO Advent Calendar 2023](https://qiita.com/advent-calendar/2023/zozo) カレンダー Vol.5 の 6日目の記事です。
:::

本記事では、GoでKubernetesリソースのメトリクスを扱う際に登場する、`k8s.io/apimachinery`モジュールの`api/resource`パッケージに含まれる`Quantity`型の扱いについてご紹介します。

# k8s.io/metricsモジュール
Kubernetesクラスタ内のPodやNode、コンテナといったオブジェクトのリソース使用量を取得する際、Kubernetes Metrics APIを利用することができます。  
Go言語でこれらのリソース使用量を取得するプログラムを実装する場合、`k8s.io/metrics`モジュールが提供する型やメソッドを利用することで、簡単にこれを実現することができます。

## Quantity型の利用箇所
`k8s.io/metrics`モジュールの中でもコンテナメトリクスの値を持つ`ContainerMetrics`型では、対象のPodで稼働するコンテナ名とリソースの使用量を保持します。

```metrics/pkg/apis/metrics/v1beta1/types.go
// ContainerMetrics sets resource usage metrics of a container.
type ContainerMetrics struct {
	// Container name corresponding to the one from pod.spec.containers.
	Name string `json:"name" protobuf:"bytes,1,opt,name=name"`
	// The memory usage is the memory working set.
	Usage v1.ResourceList `json:"usage" protobuf:"bytes,2,rep,name=usage,casttype=k8s.io/api/core/v1.ResourceList,castkey=k8s.io/api/core/v1.ResourceName,castvalue=k8s.io/apimachinery/pkg/api/resource.Quantity"`
}
```
リソース使用量は`Usage`フィールドから取得することができ、こちらのフィールドは以下で定義される`ResourceList`型を持ちます。
```api/core/v1/types.go
// ResourceName is the name identifying various resources in a ResourceList.
type ResourceName string

// Resource names must be not more than 63 characters, consisting of upper- or lower-case alphanumeric characters,
// with the -, _, and . characters allowed anywhere, except the first or last character.
// The default convention, matching that for annotations, is to use lower-case names, with dashes, rather than
// camel case, separating compound words.
// Fully-qualified resource typenames are constructed from a DNS-style subdomain, followed by a slash `/` and a name.
const (
	// CPU, in cores. (500m = .5 cores)
	ResourceCPU ResourceName = "cpu"
	// Memory, in bytes. (500Gi = 500GiB = 500 * 1024 * 1024 * 1024)
	ResourceMemory ResourceName = "memory"
	// Volume size, in bytes (e,g. 5Gi = 5GiB = 5 * 1024 * 1024 * 1024)
	ResourceStorage ResourceName = "storage"
	// Local ephemeral storage, in bytes. (500Gi = 500GiB = 500 * 1024 * 1024 * 1024)
	// The resource name for ResourceEphemeralStorage is alpha and it can change across releases.
	ResourceEphemeralStorage ResourceName = "ephemeral-storage"
)

...省略

// ResourceList is a set of (resource name, quantity) pairs.
type ResourceList map[ResourceName]resource.Quantity
```
`ResourceList`型の値はmapになっており、cpu・memory・storage・epemeral-storageの文字列をキーとして渡すことで指定した指標に紐づくリソース使用量を取得できます。  
この時、リソース使用量を表す値は以下で定義される`Quantity`型を持っています。

```apimachinery/pkg/api/resource/quantity.go
type Quantity struct {
	// i is the quantity in int64 scaled form, if d.Dec == nil
	i int64Amount
	// d is the quantity in inf.Dec form if d.Dec != nil
	d infDecAmount
	// s is the generated value of this quantity to avoid recalculation
	s string

	// Change Format at will. See the comment for Canonicalize for
	// more details.
	Format
}
```
# Quantity型の扱い
## Quantity型の定義と構成
`Quantity`型は数値を固定小数点で表します。

同じく数値を表す型で浮動小数点を扱う`float32/64`型がありますが、固定小数点の場合扱える桁数が限られる代わりに計算時の誤差が生じにくいというメリットがあります。  
`Quantity型`で扱うことができる最も小さい桁数は`10^-9`です。

`Quantity`型の持つフィールドでは、`int64Amount`・`infDecAmount`・`string`・`Format`型の値を持っています。  
ここでメトリクスの数値を表すのは`int64Amount`・`infDecAmount`・`string`型を持つフィールドとなっており、`Fomat`フィールドでは数値が表す単位系の情報を持っています。

`Format`型は次のように定義されています。
```apimachinery/pkg/api/resource/quantity.go
// Format lists the three possible formattings of a quantity.
type Format string

const (
	DecimalExponent = Format("DecimalExponent") // e.g., 12e6
	BinarySI        = Format("BinarySI")        // e.g., 12Mi (12 * 2^20)
	DecimalSI       = Format("DecimalSI")       // e.g., 12M  (12 * 10^6)
)
```
コメントで例として書かれていますが、これらはそれぞれ以下のように数値を表します。
- DecimalExponent：数値を10の累乗で表す。単位は上記の例のe6で表す。
- BinarySI：数値を2の累乗で表す。単位は上記の例のMiで表す。
- DecimalSI：数値を10の累乗で表す。単位は上記の例のMで表す。

数値を表す型のうち、`int64Amount`・`infDecAmount`型はそれぞれ次のように定義されています。
```apimachinery/pkg/api/resource/amount.go
// int64Amount represents a fixed precision numerator and arbitrary scale exponent. It is faster
// than operations on inf.Dec for values that can be represented as int64.
// +k8s:openapi-gen=true
type int64Amount struct {
	value int64
	scale Scale
}
...省略
// infDecAmount implements common operations over an inf.Dec that are specific to the quantity
// representation.
type infDecAmount struct {
	*inf.Dec
}
```
`infDecAmount`型は`inf.Dec`型のポインタとなっており、`inf.Dec`型は以下のように定義されています。
```inf/dec.go
type Dec struct {
	unscaled big.Int
	scale    Scale
}
```
このことから、それぞれの`int64Amount`・`infDecAmount`型はどちらも、整数値と小数点の位置を表すフィールドを合わせて表現される値だとわかります。
## int64Amount・infDecAmount型が持つscaleの違い
ここで、それぞれが持つ`Scale`型はそれぞれ別のパッケージで定義されているものであり、その振る舞いが異なる点に注意してください。

`int64Amount`型は0以上の大きな桁数を持つ数値を扱うのに特化しており、`infDecAmount`型は小数点以下の桁を持つ数値を扱うのに特化しています。  

例えば1,000を表す場合、それぞれの型では以下のような構造体で表現されます。
`k8s.io/api/resource`パッケージの`Scale`を使った`int64Amount`型と、`infDecAmount`型の内部で利用されている`inf`パッケージの`Dec`型ではscaleの値の正負が異なることがわかります。
```go
// int64Amountの場合
&int64Amount{
  value: 1,
  scale: 3,
}

// inf.Dec（infDecAmount）の場合
&inf.Dec{
  unscaled: 1,
  scale: -3,
}
```

`Quantity`型の構造体から各フィールドの値を取り出す際のメソッドを見ると、それぞれの型が扱う値の違いが現れています。

`Quantity`型では`Format`フィールド以外privateとなっており、メソッドを通してのみ取得可能になっています。  
`Quantity`型は多くのメソッドを持っており、`i`（`int64Amount型`）, `d`（`infDecAmount型`）フィールドの値を取得するためのメソッドとして、それぞれ`AsInt64()`・`AsDec()`メソッドが用意されています。

`AsInt64()`メソッドは次のように実装されています。
```apimachinery/pkg/api/resource/quantity.go
// AsInt64 returns a representation of the current value as an int64 if a fast conversion
// is possible. If false is returned, callers must use the inf.Dec form of this quantity.
func (q *Quantity) AsInt64() (int64, bool) {
	if q.d.Dec != nil {
		return 0, false
	}
	return q.i.AsInt64()
}
```
```apimachinery/pkg/api/resource/amount.go
// AsInt64 returns the current amount as an int64 at scale 0, or false if the value cannot be
// represented in an int64 OR would result in a loss of precision. This method is intended as
// an optimization to avoid calling AsDec.
func (a int64Amount) AsInt64() (int64, bool) {
	if a.scale == 0 {
		return a.value, true
	}
	if a.scale < 0 {
		// TODO: attempt to reduce factors, although it is assumed that factors are reduced prior
		// to the int64Amount being created.
		return 0, false
	}
	return positiveScaleInt64(a.value, a.scale)
}
```
`Quantity`型の`AsInt64()`メソッドは内部で`Int64Amount`型の`AsInt64()`メソッドを呼んでいます。  
`Int64Amount`型の`AsInt64()`メソッドでは`scale`フィールドの値が0以下である場合に値として0を返すようになっています。

一方で、`Quantity`型の`AsDec()`メソッドは次のように実装されています。
```apimachinery/pkg/api/resource/quantity.go
// AsDec returns the quantity as represented by a scaled inf.Dec.
func (q *Quantity) AsDec() *inf.Dec {
	if q.d.Dec != nil {
		return q.d.Dec
	}
	q.d.Dec = q.i.AsDec()
	q.i = int64Amount{}
	return q.d.Dec
}
```
```apimachinery/pkg/api/resource/amount.go
// AsDec returns an inf.Dec representation of this value.
func (a int64Amount) AsDec() *inf.Dec {
	var base inf.Dec
	base.SetUnscaled(a.value)
	base.SetScale(inf.Scale(-a.scale))
	return &base
}
```
こちらは内部で、`Int64Amount`型の`AsDec()`メソッドを呼び出し、その戻り値を`Quantity`型の`d`フィールドにセットしています。  
ここで`Int64Amount`型の`AsDec()`メソッドでは`Int64Amount`型の`scale`の値の正負を逆転させて、`inf.Dec`型の値への変換を行なってます。  
このことからそれぞれの型の構造体が持つ`scale`フィールドの正負が逆であるということがわかるかと思います。

名前からすると当然かもしれませんが、`Int64Amount型`が表す`scale`の値は整数部分の桁数、`InfDecAmount型`が表す`scale`の値は小数部分の桁数を表すことがわかりました。

## Quantity型を扱う例
次に実際に`Quantity型`の値を取得し、数値の大きさによって確認しやすい形式に変換して出力してみます。

例として、以下のようにコンテナのCPU・メモリ使用量を取得するプログラムを作成し、取得した値を出力してみます。

```fetch_container_metrics.go
package main

import (
	"context"
	"flag"
	"fmt"
	"os"

	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/client-go/tools/clientcmd"
	metricsClient "k8s.io/metrics/pkg/client/clientset/versioned"
)

var (
	kubeconfig string
	podName    string
)

func main() {
	flag.StringVar(&podName, "name", "", "Fetch metrics target Pod name")
	flag.StringVar(&kubeconfig, "kubeconfig", "", "kubeconfig path")
	flag.Parse()
	config, _ := clientcmd.BuildConfigFromFlags("", kubeconfig)
	clientset, _ := metricsClient.NewForConfig(config)

	podMetrics, err := clientset.MetricsV1beta1().PodMetricses("default").Get(context.TODO(), podName, metav1.GetOptions{})

	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}

	for _, containerMetrics := range podMetrics.Containers {
		cpu := containerMetrics.Usage.Cpu()
		memory := containerMetrics.Usage.Memory()
		fmt.Printf("Cpu: %#v core.\nMemory: %#v bytes.\n", cpu, memory)
	}
}

```
こちらのプログラムでは、実行時にname引数で指定した値と名前が一致するPodを取得し、`k8s.io/metrics`モジュールを利用してそのPodで稼働するコンテナのCPU・メモリ使用量を取得し、出力しています。  
`fmt.Printf()`で出力している`cpu`、`memory`変数は`Quantity`型の値を持っています。

プログラムの実行結果は次のようになります。
```bash
go run fetch_container_metrics.go --kubeconfig $HOME/.kube/config --name run-stress

Cpu: &resource.Quantity{i:resource.int64Amount{value:1000453996, scale:-9}, d:resource.infDecAmount{Dec:(*inf.Dec)(nil)}, s:"1000453996n", Format:"DecimalSI"} core.
Memory: &resource.Quantity{i:resource.int64Amount{value:1154269184, scale:0}, d:resource.infDecAmount{Dec:(*inf.Dec)(nil)}, s:"", Format:"BinarySI"} bytes.
```
出力からCPU使用量の値は`DecimalSI`の値を持ち、メモリ使用量の値は`BinarySI`の値を持つことがわかります。  
また、どちらの値も`int64Amount`の値として表されていることがわかります。

メモリ使用量はバイトで表されますが、CPU使用量はコア数で表されます。  
一般にメモリ使用量は数`Ki`バイト以上になることが多く、比較的大きい整数値を持ちます。

一方で、CPUのコア数に関しては多くても2桁程度の値になることが多いかと思います。  
またクラウドを利用する場合、VMは仮想コアを持ち、例えば0.5コアに相当する500 mCPUが割り当てられるなど小数点以下の値を持つコア数として表現されるケースがあります。

それでは、上記の例で取得したCPU・メモリ使用量を表す`Quantity`型の値を先ほど紹介した`AsInt64()`メソッドと`AsDec()`メソッドを使って確認しやすい値に変換します。  
小数点以下の値も支配的であるCPU使用量は`AsDec()`型で取得し、大きな整数値となることが予想されるメモリ使用量は`AsInt64()`型で取得します。

先に示した例を微修正し、`AsDec()`、`AsInt64()`メソッドを通して取得したCPU・メモリの使用量を出力するようにします。
```go
	for _, containerMetrics := range podMetrics.Containers {
		cpu := containerMetrics.Usage.Cpu()
		memory := containerMetrics.Usage.Memory()
		memoryInt64Amount, _ := memory.AsInt64()
		fmt.Printf("Cpu: %v core.\nMemory: %v bytes.\n", cpu.AsDec(), memoryInt64Amount)
	}
```
修正後のプログラムの実行結果は次になります。
```bash
go run fetch_container_metrics.go --kubeconfig $HOME/.kube/config --name run-stress

Cpu: 0.999451490 core.
Memory: 1154269184 bytes.
```
このようにして`Quantity`型の戻り値から、適切なメソッドを利用して確認しやすい表示形式で出力することができました。  

### （余談） vCPU → mCPUへの単位変換
CPU使用量について`vCPU` → `mCPU`に単位を変更したいケースがあるかもしれません。

この場合は`inf.Dec`型が持つ`Round`メソッドにより、`Scale`を変更し、`Unscaled()`メソッドにより`unscaled`フィールドの値を取得することで`vCPU`(コア数)から`mCPU`形式での値に変換可能です。
```go
		cpu := containerMetrics.Usage.Cpu().AsDec()
		roundedCPU := cpu.Round(cpu, 3, inf.RoundDown)
        unscaledVal, _ := roundedCPU.Unscaled()
        scaleVal := roundedCPU.Scale()
  		fmt.Printf("Cpu unscaled: %v, Cpu scale: %v., unscaledVal, scaleVal)
```
実装を修正し、再度プログラムを実行すると次の出力を得ます。
```bash
go run fetch_container_metrics.go --kubeconfig $HOME/.kube/config --name run-stress     

Cpu unscaled: 994, Cpu scaled: 3
```
`Round()`メソッドの第3引数で`RoundDown`を指定しているため、`Scale`で指定された以下の値は切り捨てられています。  
ここでは3を指定しているため、小数点以下第4位で値が切り捨てられています。

CPUの使用量は`0.994 * 10^(-3)` `vCPU`となっているため、`10^3`をかけた値が`mCPU`形式での値になります。  
そのため、`Round()`メソッドの戻り値から`Unscaled()`メソッドで取得した`unscaled`フィールドの値がそのまま`mCPU`形式の値になり、単位の変換を行うことができました。

# まとめ
本記事では、KubernetesのコンテナメトリクスをGoで扱う際に登場するQuantity型の扱いについてご紹介しました。

Quantity型と各フィールドの値について扱う際には以下の観点を意識しておくと、混乱しないかと思います。
- Quantity型は数値と単位を持つこと
- 数値を表す型は、数値の大きさにより使い分けできること
- それぞれの型で表される数値は整数値と小数点の桁数から表現されること

# 参考
- [Kubernetes Programming with Go](https://www.amazon.co.jp/Kubernetes-Programming-Go-Clients-Operators/dp/1484290259)