🔭

OpenCost で Kubernetes クラスタのコストを 可視化しよう

2022/12/16に公開

この記事は Kubernetes Advent Calendar 2022 の 16日目の記事になります

今回は最近実施されたKubeCon 2022 NA でも発表があった OpenCost という CNCF の sandbox project について調べてみました。

OpenCost ってなに?

OpenCost は CNCF で sandbox project として開発されている様々なプラットフォームで作成された Kubernetes クラスタのコストを算出するための OSS です。
VM やネットワークのコスト自体は各種パブリッククラウドなどではコンソールから簡単に算出できますが、そのコストのうち Kubernetes どのリソースがどれだけ占めているかなどを調べるのは結構手間です。
OpenCost はここらへんの手間をなくしてシュッとコストを確認できるのが良い点なのかなと思っています。
今回は実際に OpenCost を触って内部がどういう作りになっているのかを調べてみました。

OpenCost を試す

Prometheus のインストール

OpenCost はコスト計算を Prometheus のメトリクスを利用して行うので Prometheus の導入が必須です。クラスタがでかくなると Prometheus のリソースがバカにならなくなって Prometheus の運用がネックになったりしそうですね。

インストール方法は公式ドキュメントから拝借しました。

$ helm install prometheus -n monitoring --create-namespace prometheus-community/prometheus -f https://raw.githubusercontent.com/opencost/opencost/develop/kubernetes/prometheus/extraScrapeConfigs.yaml

ただhelm のバージョンが古いと下記のようなエラーでこけるので注意(1敗)

Error: template: prometheus/templates/NOTES.txt:85:46: executing "prometheus/templates/NOTES.txt" at <index .Subcharts "prometheus-pushgateway">: error calling index: index of untyped nil

helm に渡しているファイルの中身は Prometheus の scrape config です。

extraScrapeConfigs: |
  - job_name: opencost
    honor_labels: true
    scrape_interval: 1m
    scrape_timeout: 10s
    metrics_path: /metrics
    scheme: http
    dns_sd_configs:
    - names:
      - opencost.opencost
      type: 'A'
      port: 9003

Prometheus Operator などで運用する場合はこれを個別に追加してあげれば良さそうです。

OpenCost のインストール

公式が提供している All-in-One なマニフェストをそのまま利用することでインストールができます。

$ kubectl apply --namespace opencost -f https://raw.githubusercontent.com/opencost/opencost/develop/kubernetes/opencost.yaml

注意点は下記の yaml にあるように Prometheus の Endpoint を Deployment に渡してあげる必要があるので Helm で release の名前や namespace を変えていたりした場合はそれに合わせて変えてあげましょう。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: opencost
  labels:
    app: opencost
spec:
  replicas: 1
  selector:
    matchLabels:
      app: opencost
  strategy:
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 1
    type: RollingUpdate
  template:
    metadata:
      labels:
        app: opencost
    spec:
      restartPolicy: Always
      serviceAccountName: opencost
      containers:
        - image: quay.io/kubecost1/kubecost-cost-model:latest
          name: opencost
          resources:
            requests:
              cpu: "10m"
              memory: "55M"
            limits:
              cpu: "999m"
              memory: "1G"
          env:
            - name: PROMETHEUS_SERVER_ENDPOINT
              value: "http://my-prometheus-server.prometheus.svc" # The endpoint should have the form http://<service-name>.<namespace-name>.svc

opencost の Pod が立っていたらひとまず成功

NAME                        READY   STATUS    RESTARTS   AGE
opencost-75dc7dcc49-2fqvs   2/2     Running   0          55s

WebUI でコストを見てみる

ingress などはデプロイされていないので port-forward して確認します。

$ kubectl port-forward --namespace opencost service/opencost 9003 9090

(Kubernetes 5年以上触ってるけど port-forward で複数 port 一緒に forward できるのこれで初めて知りました。便利だね!)

9003 が RESTful API で、9090 が WebUI のポートです。

ブラウザで localhost:9090 を開くと次のような画面が見えるはず。

kube-system が 0.01$ 使ってるらしい。どういう計算?
コストの計算式は一旦置いておいて上のタブで Node や Pod 単位などにコストの表示を切り替えられます。

画像は Pod 単位で見てみた図。

Pod 単位でコストを見たいってケースはよくあると思うけど全ての Namespace の Pod が一覧で出てしまって Namespace でフィルタリングかけることもできないのでそこは WebUI の今後に期待したいですね。

Controller という単位もあるんですがみても何で分けてるのかよくわかんなかったです・・・
Deployment とかの単位なのだろうか・・・

ちなみにコストの確認は RESTful API もだが、kubectl plugin でも実行できるようになってます。
https://www.opencost.io/docs/kubectl-cost

plugin は krew でサクッとインストールできます。

$ kubectl krew install cost

WebUI にはないクエリのパラメータも使用できるのでちゃんとしたデータを取るには RESTful API を直で叩くか、plugin を使うかした方が良さそうです。

$ kubectl cost --service-port 9003 --service-name opencost --kubecost-namespace opencost --allocation-path /allocation/compute  \
    namespace \
    --window 5m \
    --show-efficiency=true
+-------------+-------------+--------------------+-----------------+
| CLUSTER     | NAMESPACE   | MONTHLY RATE (ALL) | COST EFFICIENCY |
+-------------+-------------+--------------------+-----------------+
| cluster-one | kube-system |          29.797795 |        0.140206 |
|             | opencost    |           0.673512 |        0.194744 |
|             | prometheus  |           0.000000 |        0.000000 |
+-------------+-------------+--------------------+-----------------+
| SUMMED      |             |          30.471307 |                 |
+-------------+-------------+--------------------+-----------------+

これ単位がないのでよくわからないが WebUI の表示と一緒ならおそらく$単位なんでしょう。

コストの算出を調べてみる

ここまでなんとなくコスト表示できるのはわかったけどどういうロジックで Monthly 29$ とか現在1$とかなのかわからなかったのでドキュメントを漁ってみると Node の費用などは算出方法が書いてありました。

https://github.com/opencost/opencost/blob/develop/spec/opencost-specv01.md#cluster-asset-costs

Pod のような Kubernetes の Workload リソースに関しては下記のように記載されてます。

The greater of requested and used CPU resources measured in cores or millicores.

問題は total cost = cores * duration * price [$] のような式にある price の部分。
これは AWS や GCP、オンプレなどで変わる部分なのでそこをどう調整しているかが気にりますね。

そこで怪しそうなのがアプライしたマニフェストのこの部分です。

env:
  - name: PROMETHEUS_SERVER_ENDPOINT
    value: "http://my-prometheus-server.prometheus.svc" # The endpoint should have the form http://<service-name>.<namespace-name>.svc
  - name: CLOUD_PROVIDER_API_KEY
    value: "AIzaSyD29bGxmHAVEOBYtgd8sYM2gM2ekfxQX4U" # The GCP Pricing API requires a key. This is supplied just for evaluation.

The GCP Pricing API requires a key. This is supplied just for evaluation.

怪しい。
この環境変数をどのように使っているのかを探ってみましょう。
そうするとクラウドプロバイダごとに実装されていて上記の API Key を使用している処理にたどりつくことができました。

// NewProvider looks at the nodespec or provider metadata server to decide which provider to instantiate.
func NewProvider(cache clustercache.ClusterCache, apiKey string, config *config.ConfigFileManager) (Provider, error) {
	nodes := cache.GetAllNodes()
	if len(nodes) == 0 {
		log.Infof("Could not locate any nodes for cluster.") // valid in ETL readonly mode
		return &CustomProvider{
			Clientset: cache,
			Config:    NewProviderConfig(config, "default.json"),
		}, nil
	}

	cp := getClusterProperties(nodes[0])

	switch cp.provider {
	case kubecost.CSVProvider:
		log.Infof("Using CSV Provider with CSV at %s", env.GetCSVPath())
		return &CSVProvider{
			CSVLocation: env.GetCSVPath(),
			CustomProvider: &CustomProvider{
				Clientset: cache,
				Config:    NewProviderConfig(config, cp.configFileName),
			},
		}, nil
	case kubecost.GCPProvider:
		log.Info("metadata reports we are in GCE")
		if apiKey == "" {
			return nil, errors.New("Supply a GCP Key to start getting data")
		}
		return &GCP{
			Clientset:        cache,
			APIKey:           apiKey,
			Config:           NewProviderConfig(config, cp.configFileName),
			clusterRegion:    cp.region,
			clusterProjectId: cp.projectID,
			metadataClient: metadata.NewClient(&http.Client{
				Transport: httputil.NewUserAgentTransport("kubecost", http.DefaultTransport),
			}),
		}, nil
	case kubecost.AWSProvider:

https://github.com/opencost/opencost/blob/49f57a3eba4caa38f483ed85a901b3ddbe7354ce/pkg/cloud/provider.go#L438

Node のリストを取得して Node のメタデータからどのプロバイダで作成されている Kubernetes ノードなのかを判別しています。
そして今回は GKE で検証しているので &GCP{...} という構造体にキーを渡しているのがわかりますね。

キーは parsePages という関数で使用されていて Google が提供している cloudbilling の API を叩いてコスト表をダウンロードしています。

func (gcp *GCP) parsePages(inputKeys map[string]Key, pvKeys map[string]PVKey) (map[string]*GCPPricing, error) {
	var pages []map[string]*GCPPricing
	c, err := gcp.GetConfig()
	if err != nil {
		return nil, err
	}
	url := "https://cloudbilling.googleapis.com/v1/services/6F81-5844-456A/skus?key=" + gcp.APIKey + "&currencyCode=" + c.CurrencyCode

これによってプロバイダごとに異なる料金を計算することができていたというわけでした。

ここからはおまけで Node のコスト周りの実装を追ってみましょう。
GCP構造体は Provider というインタフェースを実装していて次のような関数が実装されている必要があるようです。

type Provider interface {
	ClusterInfo() (map[string]string, error)
	GetAddresses() ([]byte, error)
	GetDisks() ([]byte, error)
	NodePricing(Key) (*Node, error)
	PVPricing(PVKey) (*PV, error)
	NetworkPricing() (*Network, error)           // TODO: add key interface arg for dynamic price fetching
	LoadBalancerPricing() (*LoadBalancer, error) // TODO: add key interface arg for dynamic price fetching
	AllNodePricing() (interface{}, error)
	DownloadPricingData() error
	GetKey(map[string]string, *v1.Node) Key
	GetPVKey(*v1.PersistentVolume, map[string]string, string) PVKey
	UpdateConfig(r io.Reader, updateType string) (*CustomPricing, error)
	UpdateConfigFromConfigMap(map[string]string) (*CustomPricing, error)
	GetConfig() (*CustomPricing, error)
	GetManagementPlatform() (string, error)
	GetLocalStorageQuery(time.Duration, time.Duration, bool, bool) string
	ApplyReservedInstancePricing(map[string]*Node)
	ServiceAccountStatus() *ServiceAccountStatus
	PricingSourceStatus() map[string]*PricingSource
	ClusterManagementPricing() (string, float64, error)
	CombinedDiscountForNode(string, bool, float64, float64) float64
	Regions() []string
}

Pod などのリソースの価格を計算するような関数はわからないですが NodePricing というわかりやすいものがあるのでこの実装を見てみます。

// NodePricing returns GCP pricing data for a single node
func (gcp *GCP) NodePricing(key Key) (*Node, error) {
	if n, ok := gcp.getPricing(key); ok {
		log.Debugf("Returning pricing for node %s: %+v from SKU %s", key, n.Node, n.Name)
		n.Node.BaseCPUPrice = gcp.BaseCPUPrice
		return n.Node, nil
	} else if ok := gcp.isValidPricingKey(key); ok {
		err := gcp.DownloadPricingData()
		if err != nil {
			return nil, fmt.Errorf("Download pricing data failed: %s", err.Error())
		}
		if n, ok := gcp.getPricing(key); ok {
			log.Debugf("Returning pricing for node %s: %+v from SKU %s", key, n.Node, n.Name)
			n.Node.BaseCPUPrice = gcp.BaseCPUPrice
			return n.Node, nil
		}
		log.Warnf("no pricing data found for %s: %s", key.Features(), key)
		return nil, fmt.Errorf("Warning: no pricing data found for %s", key)
	}
	return nil, fmt.Errorf("Warning: no pricing data found for %s", key)
}

DownloadPricingData関数は先ほどの parsePages を内部で呼び出していてここでコスト表をダウンロードしてきており、その後の処理は下記のような Node のラベルが関数に渡ってきているのでそれを利用してコスト表から Node のコア単価などを算出しています。

failure-domain.beta.kubernetes.io/region: asia-northeast1
failure-domain.beta.kubernetes.io/zone: asia-northeast1-a
kubernetes.io/arch: amd64
kubernetes.io/os: linux
node.kubernetes.io/instance-type: e2-medium
topology.gke.io/zone: asia-northeast1-a

まとめ

  • opencost は Kubernetes のリソース、ノード、ネットワークなどの料金を算出できる OSS
  • コストは Prometheus のメトリクスをベースに算出するので Prometheus 必須
  • コストの算出はプロバイダごとに異なる
  • WebUI はまだまだ機能が不十分な印象なので plugin か RESTful API を利用したほうが良さそう

Discussion