OpenCost で Kubernetes クラスタのコストを 可視化しよう
この記事は 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 でも実行できるようになってます。
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 の費用などは算出方法が書いてありました。
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:
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 + "¤cyCode=" + 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